engineering

Inside bext: How We Built a Single-Binary Web Runtime in Rust

When we set out to build bext, we had a simple question: what if your entire deployment stack was a single binary? No nginx config files, no certbot cron jobs, no separate cache server, no PM2 process manager. Just one binary that does everything.

The result is roughly 370,000 lines of Rust1 that terminate TLS, evaluate WAF rules, render React, cache the output, and stream the compressed response — all in the same process. This post walks through the key architecture decisions that make that possible.

Why Rust?

The short answer: we needed a language that could handle HTTP parsing, TLS termination, JavaScript rendering, image processing, and WAF rule evaluation — all in the same process — without a garbage collector pausing at the wrong moment.

Go was the obvious alternative. It's simpler, compiles fast, and has great HTTP libraries. But Go's garbage collector introduces unpredictable latency spikes under load. When you're serving heavy traffic and doing server-side rendering on the hot requests, a GC pause at the wrong moment means dropped requests.

Rust's ownership model gives us deterministic memory management. No pauses, no surprises — which matters most on the tail, where the difference between a tight p99 and a terrible one is a GC sweep you didn't schedule.

The Rendering Pipeline

bext renders pages with V8 — the same engine inside Chrome and Node.js — embedded directly in the Rust process through the rusty_v8 bindings. There's no Node.js around it: no event loop, no node_modules resolution at request time, no Express-style middleware chain. The Rust host owns the request; V8 only runs your component tree.

Crucially, V8 doesn't run inside the actix request workers. Rendering lives in a shared pool of V8 evaluation workers — separate subprocesses (bext-server --v8-eval-worker), 8 by default (BEXT_V8_POOL_SIZE), shared across every app the server hosts. One pool, not one-per-app and not one-per-request. That single decision is what makes multi-tenant hosting cheap: ten apps on one box share the same render capacity instead of each paying for an idle isolate.

Each request goes through a pipeline, and most of them never reach the render pool at all:

flowchart LR
  R(["Request"]) --> TLS["TLS"] --> WAF["WAF"] --> RT["Route match"] --> CK{"ISR cache"}
  CK -->|fresh or stale| OUT(["Stream response"])
  CK -->|miss| POOL["Shared V8 render pool"]
  CK -.->|stale: revalidate| POOL
  POOL --> CMP["Compress"] --> OUT
  classDef hot fill:#fff1f2,stroke:#f43f5e,color:#0c0c0c,stroke-width:1.5px
  class CK,POOL hot

If the ISR cache has a fresh entry, we skip the render pool entirely and serve straight from memory, without running any JavaScript. If the entry is stale but within the stale-while-revalidate window, we serve the stale copy and kick off a background revalidation — the user never waits on a render.

Compiling Routes On Demand

Your page.tsx isn't shipped to the pool as-is. The first time a route is requested, bext compiles it — through a Rust-native pipeline built on the React compiler (via oxc) and a Turbopack-style bundler — into a V8 isolate bundle that lives in worker memory. Subsequent requests reuse the compiled bundle.

The interesting problem is the first request. If a hundred requests hit an uncompiled route at once, you don't want a hundred concurrent compiles fighting for CPU. So compilation is single-flighted (BEXT_TURBOPACK_COMPILE_SINGLEFLIGHT): the first request compiles, the rest wait on the same in-flight job and reuse the result. The same idea — do the expensive work once, fan the result out to all waiters — shows up again one layer up, in the cache.

The Stampede Guard

The hardest problem in ISR caching isn't cache hits or misses. It's what happens when a popular page expires and 10,000 requests arrive simultaneously. Without protection, all 10,000 trigger a render at once and bury the pool.

Our stampede guard uses per-key coalescing. The first request for an expired key acquires the lock and renders. The other 9,999 wait on a shared future. When the render completes, every waiter gets the result at the same instant. Total renders: 1. Total responses: 10,000.

sequenceDiagram
  participant U as 10,000 requests
  participant G as Stampede guard
  participant P as V8 pool
  U->>G: popular page expires — all arrive at once
  G->>P: leader renders (1 request)
  Note over G: the other 9,999 wait on a shared future
  P-->>G: rendered HTML
  G-->>U: every waiter gets the result at once

This is why bext absorbs traffic spikes without autoscaling. The cache layer soaks up the load; the render pool only ever does the minimum work.

Keeping the Pool Alive

A shared render pool has an obvious failure mode: one wedged render — an infinite loop, a runaway regex, a synchronous call that never returns — could pin a worker forever, and enough of those would starve the whole server. We learned this the direct way, from a pool-wide stall in production, and hardened the pool around it:

  • A render deadline (BEXT_RENDER_DEADLINE_MS) calls V8's terminate_execution on a render that blows past its budget, so a CPU-wedged page can't hold a slot hostage.
  • A circuit breaker (BEXT_V8_POOL_CIRCUIT_BREAKER) quarantines a slot that keeps timing out and routes around it, instead of letting a single bad slot drag down throughput.
  • Snapshots warm fresh isolates from a prebuilt heap so a recycled worker comes back fast.
Note

The throughput number on the box is only as good as the pool's behavior on its worst request — not its average one. A shared pool turns one runaway render into everyone's problem unless you bound it.

Compression

bext compresses responses based on the client's Accept-Encoding header:

  • Brotli for the best ratio on text (HTML, CSS, JS), including static assets pre-compressed at build time.
  • Gzip as the universal fallback for clients that don't advertise Brotli.

The compression decision happens after rendering but before streaming, so the body is compressed on the way out without buffering the whole response in memory. (Zstandard shows up elsewhere in bext — for compressing deploy artifacts — but the response path stays on Brotli and gzip, which every browser understands.)

TLS Without the Complexity

bext's TLS implementation uses rustls (a pure-Rust TLS library) with automatic ACME certificate provisioning over HTTP-01. When a request arrives for a domain that doesn't have a certificate yet, bext:

  1. Serves the ACME challenge from a shared webroot — for any hostname, even one with no vhost configured yet
  2. Completes the challenge and writes the cert + key where the SNI resolver can find them
  3. Picks the new certificate up on the next handshake — no reload, no restart

Renewal happens automatically before expiry. There's no cron job, no certbot, no manual intervention — the same code path that serves your first request provisions the cert behind it.

What's Next

We're continuing to push on the pool: finer-grained scoped cache invalidation (evict only the site that changed, not all of them), off-thread compilation so a cold compile never blocks a request worker, and better observability into per-slot health. If you want to follow along, check out the docs or join the discussion on GitHub.


  1. Counted across all workspace crates. The production server binary compiles a feature-gated subset — TLS, WAF, the V8 pool, HTTP/3, and so on are each behind a Cargo feature so a build only pulls in what it serves.