2026-07-17 · ~7 min

I Finally Ran the Benchmark I Kept Promising

For two notes I said my stack was fast because it ships less, and then admitted I hadn't measured it. So I built the same blog four ways and measured it. Here is the number, and the new browser feature that pushes it close to zero.

native-first web-platform performance no-build batch

For two notes now I made the same promise and then quietly walked away from it. I said my stack ships less, so it should be faster, and then I told you I had not actually run the benchmark, so I would not print a number I could not back. Twice. At some point a bet you keep calling well-founded and never test is just a thing you are afraid to measure.

So I measured it.

The setup, before any numbers

Here is the honest version of what I built. One small blog, the same content, rendered four ways: my own native stack on Bun, the same stack with a single change I will get to, then Astro, then Next. Every one of them renders the same HTML, ships the same stylesheet byte for byte, and carries the same page metadata, the title and description and canonical link and the structured data. If one of them had better SEO, that would be a flaw in the test, not a win for the stack, so I handed all four the same head on purpose. Then one script boots each build, measures it the same way, and writes the table.

The only thing I let vary is the one piece of real interactivity on the page: a filter that narrows the post list as you type and pick tags. That filter is the whole fight. It is the exact small interaction people reach for a framework to build, so the JavaScript each stack ships to make it work is the number worth staring at.

The number

JavaScript shipped for one identical filter Astro 744 b native / BATCH 2 kb native + DPU 3 kb Next.js 118 kb Same filter, same HTML, same SEO. About 162 times the JavaScript.

The bars are to scale. That is the whole point: three of them are slivers.

For the same filter, on the same page, Next ships about 118 kilobytes of JavaScript, the React runtime plus the work of waking it up in the browser. My native stack ships about two. Astro, to its real credit, ships even less than I do, a few hundred bytes, because its filter is a small script with nothing underneath it, and on a page this static that is a genuinely strong showing. I am not going to pretend otherwise. But the shape is the argument: against my native two kilobytes that is about sixty times the code, and against Astro's few hundred bytes the whole field spreads to roughly a hundred and sixty times, for the identical thing the reader does.

A few honest asterisks, because this only means something if I do not cook it. Astro inlines its little script into the page, so a naive count would report zero, and measured properly it is those few hundred bytes, which I counted. My own server does not compress its responses in this test, which makes my numbers look slightly worse than they would behind a real CDN, so if anything the gap is wider than I am claiming. And the load times, which I did record, I am treating as corroboration and not proof, because they come off my machine with no real network in the way. The bytes are the case. The clock just nods along.

The part I did not expect to be measuring yet

There is a fourth build in that table I slid past: my stack, with one change. In the normal version, a small script I ship does the swap, taking the filtered list and putting it on the page. In this one, the browser does the swap itself.

That is Declarative Partial Updates, the proposal I flagged in the first note, and the fragment-swap path I said I had not taken in the follow-up. The server sends a piece of HTML, and the page slots it in using plain native methods, setHTML and a streaming cousin of it, with no swap library in between. There is even a way to open a hole in the page mid-stream and fill it further down in the same response, which the browser patches in with no script at all. I built that variant, pointed the same measuring script at it, and the interactive filter came in around three kilobytes of glue, while the streamed-in fill shipped, for the swap itself, nothing. The browser did it.

Why I still have not moved onto it

Because it barely exists yet, and I would rather say so than sell it. The full feature runs in exactly one browser, Chrome, and only behind a flag you turn on by hand. Firefox ships the smallest piece, the plain setHTML setter, in a normal release. Safari has not said a word. None of it is safe to put in front of a real visitor before roughly 2027, and I checked all three cases against my own demo rather than trusting a changelog, so I am confident about that shape even this early.

And even once it lands, it does not retire the small libraries. Something like htmx does far more than the swap: it decides what triggers a request, how the request is shaped, how the back button still works. Declarative Partial Updates takes only the swap, the one bit of glue I had to import, and hands it to the browser. The runtime you ship gets smaller. It does not go to zero. There is also a trade I am not hiding: the native-swap version asks the server for each fragment, so it spends a round-trip where my plain client-side filter spends none. Fewer bytes, one more request. On that one I took the measurement, not a side.

The promise, kept

So here is the number I owed across two notes, with a receipt under it at last. Less shipped really is less, by about two orders of magnitude at the far end, for the same page doing the same job. And the one piece of glue I was still importing, the browser is reaching down to pick up on its own.

I spent two posts saying the browser grew up. Turns out it is still growing, and this time I stayed long enough to time it.

The bet I kept calling well-founded is measured now. It held.

(The full numbers live on the bench results page, a static page that itself ships 0kb of JavaScript. The harness and the four builds live in the framework bench repo, so you can run it yourself and get your own numbers.)


The judgment is human. The typing, by design, is not.