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
| Framework | Client JS (gz) | TTFB | RPS (uncached) | p99 | Build | Interactions |
|---|---|---|---|---|---|---|
| bext (PRISM) | 5.6 KB | 6.0 ms | 4,742 | 71 ms | ~4 KB* | ✅ |
| Astro | 0.33 KB | 12.3 ms | 3,807 | 20 ms | 432 KB | ✅ |
| Remix | 101 KB | 12.2 ms | 1,011 | 75 ms | 335 KB | ✅ |
| Qwik | 35 KB | 42.6 ms | 938 | 73 ms | 249 KB | ✅ |
| Next.js | 145 KB | 21.1 ms | 575 | 141 ms | 5.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.nextand 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:
# 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.jsonWe'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.