engineering

Five Frameworks, One App: an honest, uncached benchmark

Framework benchmarks are usually rigged — by the author's framework, by a synthetic "hello world," or by quietly comparing a cached response against an uncached one. So we did the opposite: one identical app, built idiomatically in five frameworks, every one forced to actually render on every request, and the fairness proven in the output.

The contenders: Next.js (App Router), Remix (React Router v7), Astro (islands), Qwik (resumable), and bext (our Rust runtime + PRISM). Same machine, production builds, production servers, loopback so the network isn't the variable.

The app

A single server-rendered page — call it Widget Showcase:

  • a header with a theme toggle (persists to localStorage),
  • a hero,
  • a counter (click to increment),
  • a search box that filters a grid of 24 server-rendered product cards live,
  • a footer.

The 24 cards come from one shared products.json and must be in the initial HTML (no client-only list rendering). Each framework uses its idiomatic interactivity primitive: Next/Remix "use client" components, Astro islands, Qwik resumable components, bext "use resumable" islands. All three interactions on all five apps were clicked and verified in a real headless browser before any number was recorded.

The fairness rule: render every request, and prove it

This is the part most comparisons skip. A "fast" SSR number is often a cached or prerendered number. So we forced each app to render fresh on every request — export const dynamic = "force-dynamic" (Next, bext), prerender = false (Astro), per-request loaders (Remix, Qwik) — and proved it: each page renders a data-rendered-at timestamp, and we required it to change between two back-to-back requests. All five verified uncached = true. No cache, no prerender, nothing up the sleeve.

The results

FrameworkClient JS (gz)TTFBRPS (uncached)p99BuildInteractions
bext (PRISM)5.6 KB6.0 ms4,74271 ms~4 KB*
Astro0.33 KB12.3 ms3,80720 ms432 KB
Remix101 KB12.2 ms1,01175 ms335 KB
Qwik35 KB42.6 ms93873 ms249 KB
Next.js145 KB21.1 ms575141 ms5.2 MB

oha -z 8s -c 50. Client JS = every JS resource + inline scripts the page loads, gzipped by us at one fixed level (a uniform encoder, not each server's choice).

Reading the numbers

bext is the fastest renderer — for real, not via cache. Doing a full V8 render on every request, the Rust core sustains 4,742 RPS at a 6 ms first byte — the highest throughput and lowest TTFB of the five.

The fairness rule cost Next.js the most. Cached/prerendered, Next served 2,945 RPS. Forced to actually SSR per request, it fell to 575 — a 5× drop. It had been handing back prerendered static HTML, not rendering. That's precisely the bias the proof step exists to catch, and it caught it. Remix, Astro, and Qwik barely moved because they were already rendering per request.

Astro is the genuine rival. 3,807 RPS and the tightest tail latency (p99 20 ms), because its per-request render is the lightest — it ships essentially no runtime, so there's little to do. bext pushes more total throughput; Astro has the calmer tail. That's an honest trade, and Astro earns it.

Client JS: kilobytes, not megabytes. Caching doesn't touch this column. bext ships 5.6 KB to make the page interactive — three resumable islands plus a shared runtime cached once, or ~0.8 KB in lazy mode (nothing until you interact). That's ~18× less than Qwik and ~26× less than Next.js. Astro wins outright at 0.33 KB — but that's hand-written vanilla <script> with no runtime; bext's 5.6 KB buys a real signals/resume runtime that scales to genuine reactive components the vanilla approach doesn't. bext sits with Astro and Qwik in the lightweight camp and laps the React frameworks.

And bext keeps its trump card. Uncached, it's already the fastest. Turn on its built-in ISR cache — one line of config, not an external CDN — and the same route serves 282,776 RPS at 0.13 ms TTFB / 0.64 ms p99. The 4,742 above is bext's worst case. In the deployment mode you'd actually ship, it's in a different universe.

What these numbers do not say

Numbers without caveats are marketing, so:

  • Time-to-interactive was inconclusive (~51–55 ms for everyone). On a fast machine over loopback, even 145 KB of React hydrates within a frame, so "time until the counter clicks" is dominated by test latency, not the model. The hydration-vs-resumption gap shows up on throttled mobile CPUs and real networks — which this rig doesn't simulate. The JS-size and RPS/TTFB columns are the ones that discriminate here.
  • This is one app on one machine. A different app (heavy client state, a data layer, a streaming route) would shift the picture. We picked one that every framework can do idiomatically and that exercises SSR + real interactivity.
  • Astro's 0.33 KB is vanilla DOM, not a framework runtime. Fair for this app; different math once you need real reactive components.
  • * bext has no heavyweight build artifact (it compiles routes on demand; ~4 KB bundle cache) and no process-boot cold start — the Rust master is always warm. Next's 5.2 MB .next and its 510 ms boot are the outliers in those columns.

Reproduce it

Everything — the five apps, the harness, and the raw results — is public: github.com/benfavre/same-app-five-frameworks. The five apps, the shared products.json, the load tests, the browser interaction checks, and the per-request data-rendered-at proof files are all there. The short version:

Code
# build all five, then for each: cold-start, TTFB, RPS, browser client-JS + interactions
bash harness/run-all.sh
python3 harness/summarize.py     # -> results table + results.json

We'd rather you re-run it than take our word for it. That's the whole point of measuring on every request and proving the proof.