Throughput Benchmarks
The six canonical TechEmpower tests —
JSON serialization, Plaintext, Single database query, Multiple queries, Fortunes,
and Data updates — run against bext and four Node frameworks on the same dataset
and workload. Every response is uncached / force-dynamic: a real V8 render on
every request, no full-response caching.
Hardware & method. 24-core shared production box, loopback, oha
load generator, peak requests/sec across concurrency {64, 256}, best of repeated
runs, gated at 100% success. The box is shared, so absolute numbers move with
load — ratios are stable. Every framework queries the same SQLite dataset (World
10k rows + 12 fortunes). Reproduce with harness/tfb-bench.mjs.
Single serialized worker vs the performant default#
bext's render workers can run serialized (one in-flight render on one worker, the conservative per-core floor) or as the multiplexed pool it ships with (workers demux responses by request id, awaited directly on the HTTP worker). The default is the pool.
| Test | 1 serialized worker | Performant default (4-worker pool) |
|---|---|---|
| JSON | 6,419 | 99,174 |
| Plaintext | 7,060 | 102,265 |
| Single query | 5,948 | 100,183 |
| Multiple queries (20) | 4,023 | 84,945 |
| Fortunes | 4,519 | 95,296 |
| Data updates (20) | 2,076 | 99,590 |
| p99 latency | 75–235 ms (queued) | 8–16 ms |
The default uses four render workers (four cores) against the serialized worker's one — so this is a config and a parallelism gain, not per-core. What the pool adds beyond the extra cores: it removes the single-in-flight serialization (a serialized worker idles between IPC round-trips) and, with the async eval path, the per-request scheduler churn — so throughput scales with workers where a naive blocking pool regresses below one worker. Data updates jump the most (48×) because serialized writes become concurrent.
vs Next.js, Remix, Astro, Qwik#
Performant default, each framework as one server process (the Node apps run
single-threaded — their default node server.js):
| Framework | JSON | Plaintext | Single query | Multi query (20) | Fortunes | Updates (20) |
|---|---|---|---|---|---|---|
| bext (performant default) | 91,047 | 96,579 | 89,613 | 81,407 | 91,320 | 90,803 |
| bext (1 serialized worker) | 6,419 | 7,060 | 5,948 | 4,023 | 4,519 | 2,076 |
| Astro | 5,512 | 5,214 | 4,791 | 4,118 | 5,027 | 796 |
| Qwik | 4,788 | 5,038 | 4,696 | 3,284 | 4,400 | 766 |
| Remix | 3,787 | 4,240 | 3,875 | 2,950 | 4,069 | 791 |
| Next.js | 2,945 | 3,245 | 3,149 | 2,623 | 3,081 | 711 |
The 1 serialized worker row is the same conservative config as the left column of the table above — one in-flight render on one worker, no multiplexing. Even there bext meets or beats the other frameworks' single-process throughput on most tests (and is far ahead on Updates), while doing a full V8 render per request; a serialized worker is latency-bound, so its numbers are stable regardless of box load.
p99 at peak: bext (performant default) 10–17 ms; the others 18–82 ms. A single
bext server uses the whole machine — its Rust master fans requests across a
multiplexed V8 render pool — where a single node process runs one event loop and
needs an external cluster manager to use more cores. Give the Node apps cluster
with N workers and their numbers rise roughly N×; the per-server comparison above
reflects each framework's default single-process deployment.
PRISM page rendering & caching#
This is a separate request-level measurement captured on 2026-07-05. It uses a different page, worker configuration, and HTTP load generator from the current 38.3 KB rendering-only PRISM shootout; do not derive per-render engine ratios by combining the two tables.
The tests above are minimal handlers. A real PRISM page — the "Widget Showcase": a server-rendered component tree with three resumable islands and a product grid — is a much heavier render. The same page, measured across three cache states:
| Config | rps | What runs |
|---|---|---|
| 1 serialized worker | 1,450 | full V8 render, one in-flight |
Performant default (-w4) |
30,174 | full V8 render, multiplexed pool |
| ISR cache hit | ~456,000 | stored HTML served from memory, no render |
The uncached render runs the entire component tree in V8 on every request (a real
page is ~20× heavier than the trivial JSON/query handlers, hence the lower
numbers). The ISR cache hit serves the previously-rendered HTML straight from
memory — no V8, no loader — a ~15× jump over the live render and ~5× the fastest
uncached handler. Real sites mix these: force-dynamic routes render every time,
ISR / static routes serve from cache, and bext additionally short-circuits
repeat renders through a per-loader read-through cache and a
determinism-by-observation render cache when a route's output proves stable.
What drives the numbers#
- No per-request filesystem work. Route discovery and each route's compiled bundle are cached; master syscalls dropped from ~67 to ~15 per request.
- Multiplexed pool by default (
BEXT_V8_POOL): the render thread never idles on the IPC round-trip. - Async eval (
BEXT_V8_ASYNC_EVAL): the HTTP worker awaits the render instead of parking a blocking thread, halving per-request context switches and letting the pool scale. - Tuned SQLite on the in-process render DB path (WAL,
synchronous=NORMAL, mmap) plus a batched query primitive — one V8 crossing for N reads, one transaction for N writes.