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
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.
<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++;
}
}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);
}create
The element tree is built once, imperatively. Static markup is static code.
signal
private int currentCount lifts to signal(0) at compile time. You never wrote it.
effect
One effect per binding. @currentCount → one node updated, nothing diffed.
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.
C1 · Bundle weight
Counter gzip. Rows 4,373 B. The signals runtime fits in a 2 KB budget.
C3 · DOM writes
Rows #update: 100 characterData writes, zero reconcile. #swap: exactly 2 node moves.
C4 · Create-warm
Rows-gen vs 7.90 ms for the faster Blazor/AOT, and it wins on update, swap, and clear too.
C4 · Increment
A conservative floor: the real figure is below the performance.now() quantum, so it isn't quoted higher.
BENCH.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-written
Rowsbundle 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
@onclickyou 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.