Stéphane De Todaro — tech lead

@super-dev.app · Tech-lead
Active since 2017

Technical lead and full-stack architect, freelance since 2019. I design, industrialize and operate business platforms on Azure, and I publish open-source software engines.

Back to articles
{{ }}
.NET
.NET

Interpret, don't compile: a template engine for .NET

The next .NET template engines compile. Razor goes through Roslyn, Handlebars emits IL, and the first render pays the bill: tens of milliseconds of code generation before the first byte.

In a long-lived process, this cost amortizes. In a serverless function or a container started on demand, you pay it again on every wake-up.

NgSharp does the opposite: the template is parsed into an AST then interpreted . Nothing to compile, zero third-party dependencies, and v3 holds up in warm-path comparisons too, benchmarks to back it up.

The compilation cliff

Same 96-product catalog, same HTML output. RazorLight takes ~29 ms on first render, the time it takes Roslyn to compile. Handlebars, ~10 ms of IL emission. NgSharp, 32 µs .

The gap doesn't come from the rendering itself but from everything that doesn't happen: no compilation, no generated code to load.

Code generation also has an access cost: Native AOT and *trimming* exclude Roslyn and Reflection.Emit . An interpreted engine simply doesn't have that constraint.

What interpreting means

A compiled engine translates the template into source code or IL, then lets the JIT turn it into cached machine code. The first render pays for this translation; subsequent ones reuse the code already produced. The trade-off pays off when the same template is rendered thousands of times in a process that never restarts.

An interpreted engine stops earlier. The template is read only once, in two steps.

First the lexer splits the string into tokens: blocks of literal text and delimited expression regions ( {{ }} , control-flow blocks). Its only job is to find the boundaries.

Then the parser assembles these tokens into a tree. Each language construct becomes a node type: a literal carries static text, an interpolation carries an expression, a loop carries its source and the body to repeat.

C#
1// Conceptual shape of a template AST: one node type per construct.
2abstract record Node;
3record Literal(string Text) : Node; // static markup, copied as-is
4record Interpolation(Expr Value) : Node; // {{ expr }}, escaped when written
5record ForLoop(string Var, Expr Source, Node[] Body) : Node;
6record If(Expr Condition, Node[] Then, Node[] Else) : Node;

This tree is built once. Nothing is compiled, nothing is emitted, nothing is loaded. This is what makes the first render nearly free: all that's left is to walk the tree.

Walking the tree on every render

Rendering means walking the tree depth-first and writing into an output buffer. A literal node copies its text as-is. An interpolation evaluates its expression against the model and writes the result, escaped. A loop evaluates its source and re-emits its body once per element.

C#
1// Render = walk the tree once, writing straight into the output buffer.
2void Write(Node node, Scope scope, IBufferWriter<char> output)
3{
4 switch (node)
5 {
6 case Literal l: output.Write(l.Text); break;
7 case Interpolation i: output.WriteEscaped(Eval(i.Value, scope)); break;
8 case ForLoop f:
9 foreach (var item in Eval(f.Source, scope))
10 foreach (var child in f.Body)
11 Write(child, scope.With(f.Var, item), output);
12 break;
13 // ... one arm per node type
14 }
15}

The cost per render is this walk: a dispatch per node, model reads, and string writes. Conventional wisdom says a compiler wins here, because it replaces dispatch with straight-line code. The gap closes when the dominant work isn't dispatch but text writing and model access: a template does little arithmetic and a lot of concatenation.

What sinks a naive interpreter is allocation. A context object per node, an intermediate string per interpolation, and the GC ends up dominating render time. An engine that wants to hold up under load keeps this path allocation-free. That's precisely what the v3 rewrite targets.

Escaping happens at write time, not at parse time. Literals pass through untouched; only interpolated values are escaped before reaching the buffer. That's the boundary between markup intended by the author and content coming from the data.

Angular-style templates, without the dependency

The syntax mirrors Angular's: {{ }} interpolation, pipes, [attr.x] / [class.x] bindings, server components, @if / @for / @switch control flow.

C#
1var builder = HtmlBuilder.Create(); // pre-loaded with the built-in pipes
2
3var html = builder.BuildFromTemplate(
4 "<ul>@for (u of Users) {<li>{{ u.Name | upper }}</li>}</ul>",
5 new { Users = new[] { new { Name = "ada" }, new { Name = "linus" } } });
6
7// → <ul><li>ADA</li><li>LINUS</li></ul>

The HTML parser was written specifically for this, without AngleSharp. It does just one thing: produce structurally correct, escaped output.

Since v3, the engine also accepts things other than HTML: TemplateMode.Text runs plain text, JSON, or CSV through the same pipeline, for text emails and exports.

The documentation walks through every directive, pipe, and binding with runnable examples.

What v3 changes

The core was rewritten: single-pass parser, lazy and copy-free model reads, inline caches on property accesses, pipes formatted on Span with no allocation. The AST is immutable and the renderer stateless, so a template parsed once renders in parallel, lock-free.

C#
1builder.BuildFromTemplate(template, model, new TemplateOptions
2{
3 Strict = true, // fail loud: silent misses become NgSharpException
4 Culture = new CultureInfo("fr-FR"), // per-render pipe formatting
5 Limits = new RenderLimits(), // resource caps
6});

Strict mode turns silent failures into NgSharpException , and catches, right at template analysis, an always-false condition or a division by a literal zero.

Byte-identical output

Template engine comparisons rarely compare the same thing, since each engine renders slightly different HTML. Here, the six engines measured (NgSharp, RazorLight, Handlebars, Fluid, Scriban, Stubble) produce byte-identical output, and a CI gate verifies it before measuring anything at all.

On the catalog, NgSharp renders warm in 25 µs for 33 KB allocated, ahead of every engine measured, in both time and allocations. The whole thing is covered by 704 tests .

Where it runs

netstandard2.1 and net8.0 , IsAotCompatible , a single NuGet dependency ( System.Text.Json ). The TextWriter and RenderAsync *sinks* write atomically: a render that fails writes nothing at all. The package is on NuGet : dotnet add package NgSharp .

Compiling templates buys warm-path speed at the price of a cold-start cliff. v3 shows the trade-off isn't mandatory: interpret, and still stay ahead when warm too. The benchmarks re-run with a single command from the repo.
Stéphane De Todaro — super-dev.app
// More in .NET
Externalizing SQL in EF Core migrations: procedures and views as versioned files
Aug 14, 2026 • 8 min
Orchestrating without a database: a ProcessAPI / SystemAPI split in .NET
Aug 14, 2026 • 10 min
{ }
Strangle a .NET 8 monolith without breaking everything
May 2, 2026 • 6 min