Skip to content

A research compiler · every number measured against Blazor

Blazor components.
Compiled to JavaScript.

Filament turns Blazor-style .razor components (template and C# @code) into lean static HTML + JS and a tiny signals runtime. No .NET in the browser. No megabyte download. No virtual DOM.

Counter bundle (gzip)
2,987B
Signals runtime
<2KB
.NET in browser
0bytes
Update cost
1DOM write

The problem

Blazor WebAssembly ships a runtime the size of an application.

The component model is excellent. The delivery isn't. To render a counter, a Blazor WASM app downloads and boots the .NET runtime in the browser before a single pixel appears: an interpreter or AOT payload to warm, and a render tree that diffs on every change.

A 1,000-row grid, gzipped, over the wire

Blazor WASM 1.88 MBFilament 4.4 KB
~432×

lighter

Weight

The framework is the payload. Your code is a rounding error next to the runtime it rides in on.

Startup

Nothing renders until .NET is fetched, instantiated, and warm. First paint waits on a VM.

DOM churn

A render tree re-diffs to find what changed. The machinery to compute one text update isn't free.

The question Filament asks: what if the component model stayed, and the runtime left?

How it works

Compile the component at build time. Ship the result, not the framework.

Filament reads the same .razor file Blazor compiles and lowers it, with Roslyn, to plain, imperative JavaScript. Static structure becomes create-once DOM calls. State becomes a signal. Each binding becomes one effect. There is no template to parse at runtime and no virtual DOM to diff.

Counter.razor · what you write
<h1 id="title">Counter</h1>

<p>Current count: <span id="counter-value">@currentCount</span></p>

<button id="increment" @onclick="Increment">Click me</button>

@code {
    private int currentCount = 0;

    private void Increment()
    {
        currentCount++;
    }
}
mount() · what ships (abridged)
export function mount(target) {
  const currentCount = signal(0);

  const p = document.createElement('p');
  insert(p, document.createTextNode('Current count: '));
  const span = document.createElement('span');
  span.id = 'counter-value';
  const t = document.createTextNode('');
  insert(span, t); insert(p, span);

  const button = document.createElement('button');
  button.id = 'increment';
  insert(button, document.createTextNode('Click me'));

  // one binding point -> one effect
  effect(() => setText(t, currentCount.value));
  listen(button, 'click', () => { currentCount.value++; });

  insert(target, p);
  insert(target, button);
}
01

create

The element tree is built once, imperatively. Static markup is static code.

02

signal

private int currentCount lifts to signal(0) at compile time. You never wrote it.

03

effect

One effect per binding. @currentCount → one node updated, nothing diffed.

04

listen

@onclick maps to a single event listener. The handler body is translated, not spliced.

The evidence

Four criteria. Measured, not asserted.

Two demo apps, Counter and a 1,000-rowRows, compiled from pure .razor and benchmarked against the same apps built with Blazor WASM (interpreted and AOT). For the app-level head-to-head (a routed task board with a form, a keyed list and per-row handlers, measured on weight, time-to-interactive and memory), seethe Duel.

2,987B

C1 · Bundle weight

Counter gzip. Rows 4,373 B. The signals runtime fits in a 2 KB budget.

1write

C3 · DOM writes

Rows #update: 100 characterData writes, zero reconcile. #swap: exactly 2 node moves.

3.10ms

C4 · Create-warm

Rows-gen vs 7.90 ms for the faster Blazor/AOT, and it wins on update, swap, and clear too.

≥11×

C4 · Increment

A conservative floor: the real figure is below the performance.now() quantum, so it isn't quoted higher.

The honest ceiling. C1 (weight) and C4 (speed) pass for both apps, including the full 1,000-row DOM work. These areprovisional research measurements: coarse ratios are not cited to three significant figures, and every figure carries a disclosed reserve inBENCH.md. Demo apps, plus a persisted todo app and a head-to-head task board, show the architecture is viable; they do not, by themselves, prove it out as a whole framework. What remains untested is scale, not surface.

What compiles

A wide C# subset, and a compiler that refuses the rest, loudly.

Across 164 recorded decisions and 70 measured entries, the compilable surface has grown to cover most of everyday C# and, since ADR 0003, the framework layer too. Anything outside it raises a located diagnostic and writes no file.

Control flow

@if / @else / @else if, @foreach, nested & multi-node bodies, root-level conditionals

C# statements

for, while, do-while, switch, try/catch, throw, lock, local declarations, compound assignment

Numeric types

int, long → BigInt, float → Math.fround, decimal (boxed), DateTime → tick math

Collections

List<T>, T[], Dictionary<K,V>: read, reassign, and element write (copy-on-write signals)

LINQ

Where / Select / OrderBy / GroupBy / Sum / First / Skip / Take: the whole common surface

Reactivity & events

@bind two-way, lambda handlers, reactive attributes, async/await → Promise

The framework layer

@page routing, @inject, @inherits, RenderFragment / ChildContent, EventCallback, @ref, CascadingParameter, generics, JS interop, EditForm: all eleven closed and measured

Error boundaries

<ErrorBoundary> catches what the parent throws while evaluating its content, latches the first error the way Blazor does, and adds zero runtime bytes

Real-world I/O

HttpClient → fetch, JSON under a shape gate, the wall clock, seeded Random proven against the BCL, OnInitialized(Async), computed properties

Backed by 571 .NET tests and214 runtime tests, every slice verified byte-for-byte against a Blazor-faithful answer key, then measured live in a real browser.

What it doesn't do yet

The verdict, stated plainly: not eliminated, not established.

Filament is a thesis under test, not a shipping framework. The framework layer is no longer the caveat it was: routing, DI, inheritance, fragments, callbacks, refs, cascades, generics and interop are implemented and measured, and ten of the eleven costzero runtime bytes because the compile-time model absorbed them rather than being extended by them. What is honest to say now is narrower, and sharper.

A ten-agent probe then went back into those eleven features and wrote its own witnesses. It found the surface was closed at the centre and open at the edges: a form that navigates away on submit, a fragment forwarded twice and dropped, a parameterised route that renders a blank screen. Those defects are catalogued, each with the command that produced it, and they are being closed one measured slice at a time. The register is in the repository, because a limit you can read is a design constraint and a limit you discover in production is a bug report.

Not implemented

  • SSR / prerendering
  • CSS isolation (.razor.css)
  • Form validation (refused, not ignored)
  • A general DI container
  • OnAfterRender / OnParametersSet / IDisposable
  • Templated components (RenderFragment<T>)
  • Route parameters (:guid, catch-all {*Rest}, and optional {id?})
  • Error boundaries: nested, at a template root, a second one in the same component, content that reads a computed, MaximumErrorCount

Reserves

  • Banked. The hand-writtenRows bundle was re-measured on the wire: 4,373 B, byte-identical to the generated one.
  • Banked. The comment-anchor node debt was re-measured inside a conditional app: Filament renders 4 nodes to Blazor's 5. It never flips sign; it stays an advantage.
  • Measured before it was mapped. A boundary catches what its descendants throw. A@onclick you write inside the boundary belongs to the parent that wrote the fragment, so it is not caught here. That is not a shortcut: it was measured against the real Blazor renderer first, and Blazor does not catch it either.
  • Open, mitigated. The generator pins a frozen, out-of-support Razor toolchain, now contained to one seam, hardened to fail loud, and mapped for migration. An architectural liability the thesis carries as its price.

Why say all this? Because the project's one non-negotiable rule is that it never claims more than it measured. The evidence says the architecture is viable: smaller and faster on real work. It does not yet say it replaces Blazor. That distinction is the whole point.