bext.dev
DocsOperations
Operations4 min read
On this page

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.
Edit this page ↗Need a hand? ↗
FIND YOUR NEXT STEP

Start with a topic, a command, or a question.