V8 Render Pool
The V8 render pool is the subsystem that actually runs server-rendered JavaScript — PRISM pages, Next.js/RSC routes, and fetch-handler apps — inside V8 isolates. It is the last stage of the PRISM rendering pipeline and the most operationally sensitive part of the server: a wedged isolate can stall every site sharing it.
This page covers the architecture — topology, scheduling, resilience. For the configuration knobs as a reference, see V8 Render Engine; for runtime symptoms, see scaling and troubleshooting.
Topology
In production, V8 runs in a global, out-of-process pool shared across
every site — SubprocessPool in bext-v8/src/subprocess.rs,
installed as an Arc<dyn EvalBackend>:
actix / tokio HTTP front (num_cpus worker threads)
│ per SSR request → spawn_blocking
│ route match → compile bundle → send eval frame → recv_timeout
│
│ framed, big-endian, over Unix socketpairs
├───────┬───────┬───────┬─── … ───┬───────
worker0 worker1 worker2 worker7
1 OS process · 1 V8 isolate · 1 eval thread each
└─ multiplex::drive(): N concurrent requests share the isolate
Two facts drive everything else:
- The pool is global and undifferentiated. By default an SSE-streaming route and a render-heavy page route compete for the same isolates. (Workload isolation is opt-in — see streaming lanes.)
- Concurrency is two-layered. The master schedules requests across workers; inside each worker the multiplex driver runs several requests on one isolate cooperatively.
The pool backends
| Backend | File | Activation | Shape |
|---|---|---|---|
SubprocessPool |
bext-v8/src/subprocess.rs |
BEXT_V8_POOL=1 + BEXT_V8_POOL_SIZE=N |
N child processes, crash-isolated, the production topology. |
In-process PrismPool |
bext-v8/src/prism_pool.rs |
default (BEXT_PRISM_WORKERS, default 2) |
OS threads in the master process, long-lived isolates. |
| Single eval thread | bext-v8/src/eval.rs |
fallback | One bext-v8-eval-worker thread serializing all renders. |
| Legacy per-site React pool | bext-v8/src/pool.rs |
Next.js sites | V8RenderPool, per-site worker set. |
BEXT_V8_POOL_SIZE is clamped to 1–16; production runs 8.
Subprocess mode exists for crash isolation: a V8 SIGSEGV takes down one
worker, not the master. (It is also load-bearing for memory — an early
in-process variant leaked a 'static isolate heap that ballooned the
master to ~30 GB, so subprocess mode must never silently fall back to
the in-process pool.)
Inside a worker: the multiplex driver
multiplex::drive() (bext-v8/src/multiplex.rs, gated on
BEXT_V8_MULTIPLEX=1) is one loop owning one isolate that interleaves
many in-flight renders:
- drain newly-arrived requests from an mpsc channel,
- resolve completed loopback fetches (
drain_async_fetches), - pump JS microtasks and timers (
pump_timers_and_microtasks), - park event-driven on a wake channel (
DRIVER_WAKE_TX, poked bywake_driver()on enqueue / fetch completion) for up toMAX_PARK_MS = 50 ms.
This is what lets 8 isolates serve far more than 8 concurrent requests:
while one render is awaiting a loader's fetch, the driver advances
others on the same isolate.
Warm contexts and snapshots
Cold V8 work is amortized at two levels (bext-v8/src/eval.rs):
- Warm contexts —
prism_contexts/page_contexts/api_contextskeep an evaluated bundle's context alive (keyed byprism_cache_key(shell, bundle)), so repeat renders of a route skip re-evaluating the bundle. - Startup snapshot —
snapshot.rscan bake React + polyfills into a V8 startup blob (SNAPSHOT_BLOB) so a fresh context starts pre-initialized; the blob is forwarded to subprocess children.
Per-isolate hygiene: PRISM workers cap the heap
(BEXT_PRISM_HEAP_MB, default 64) and issue a
low_memory_notification every BEXT_V8_GC_EVERY_REQUESTS renders
(default 100).
Scheduling, admission & backpressure
Admission is enforced by the master before a frame is dispatched
(subprocess.rs):
- Global in-flight cap —
BEXT_V8_POOL_QUEUE_DEPTH(default 64, clamped 1–4096).try_admit()CAS-increments a global counter; over the cap, the request is rejected and counted inbext_v8_pool_rejections_total. - Per-worker cap —
BEXT_V8_POOL_PER_WORKER_CAP(default 8 non-multiplex / 16 multiplex). Workers at their cap are skipped by the scheduler. - Least-in-flight selection —
pick_and_claim_workerpicks the live, non-quarantined worker with the fewest in-flight requests in the appropriate lane. - Dynamic capacity (Phase 4.5) — workers over a heap-soft-limit or hard-parked-cap are filtered out, each with its own rejection-reason counter.
A rejection becomes a clean 503 with Retry-After: 1 rather than
a stalled connection. With BEXT_PRISM_EARLY_SHED=1, that shed happens
before the compile + wrapper write
(prism.rs::pool_admission_saturated), so a request destined for
rejection doesn't first burn compile CPU — the response carries
x-bext-pool-shed: queue-full.
Streaming lanes
By default render and streaming requests share all workers, which is
exactly how a single long SSE stream can starve page renders. Setting
BEXT_V8_POOL_STREAMING_LANE=N reserves the last N slots as a
streaming-only lane (Lane::Streaming); the first pool_size − N slots
become render-only (Lane::Render). The split is strict — no spill
in either direction — so a stuck stream can never claim a render slot.
Watchdog & resilience
Three independent safety mechanisms guard against a wedged isolate.
1. The eval timeout / SIGKILL watchdog (master-side)
The master waits on recv_timeout for each frame:
SUBPROCESS_EVAL_TIMEOUT_SECS = 30 for renders,
SUBPROCESS_STREAMING_TIMEOUT_SECS = 300 for streams (compile-time
constants). On breach it marks the worker WORKER_DEAD,
SIGKILLs the PID, records the timeout, and the per-slot supervisor
respawns it (budget: 5 crashes / 60 s → 60 s cooldown).
This is a blunt instrument — it measures wire round-trip, not CPU progress, and it kills the entire isolate (and every request multiplexed onto it). That bluntness is the root of the freeze below; the next two mechanisms exist to avoid ever reaching it.
2. The in-V8 render deadline (render_deadline.rs)
BEXT_RENDER_DEADLINE_MS (default 0 = off; recommended 28000, i.e.
2 s under the SIGKILL) arms a watchdog thread that polls every
WATCHDOG_TICK_MS = 50 ms and calls isolate.terminate_execution() on
a CPU-wedged synchronous render at the next V8 safepoint. It is
multiplex-safe: only process_request arms it, so it never
terminates a co-tenant's work. The eval thread calls
cancel_terminate_execution() after unwinding so the isolate stays
reusable — a graceful kill of one render instead of a SIGKILL of the
worker.
3. The circuit breaker (quarantine)
BEXT_V8_POOL_CIRCUIT_BREAKER=1 makes a timed-out slot get
quarantined for BEXT_V8_POOL_QUARANTINE_MS (default 5000, clamped
500–120000). The scheduler deprioritizes quarantined slots (still
usable as a last resort if all slots are quarantined, counted via
quarantine_fallbacks). Quarantine state is keyed by slot index, so it
survives the SIGKILL + respawn of the underlying process.
Environment variables
A full reference is in V8 Render Engine; the architecture-relevant subset:
| Env var | Default | Effect |
|---|---|---|
BEXT_V8_POOL |
off | Activate the subprocess pool (set with POOL_SIZE). |
BEXT_V8_POOL_SIZE |
1 (prod 8) | Worker process count; clamped 1–16. |
BEXT_V8_MULTIPLEX |
off (prod 1) | Cooperative multiplex driver per worker. |
BEXT_V8_POOL_QUEUE_DEPTH |
64 | Global in-flight admission cap (1–4096). |
BEXT_V8_POOL_PER_WORKER_CAP |
8 / 16 | Per-worker concurrency cap. |
BEXT_V8_POOL_CIRCUIT_BREAKER |
off | Quarantine wedged slots. |
BEXT_V8_POOL_QUARANTINE_MS |
5000 | Quarantine cool-off (500–120000). |
BEXT_V8_POOL_STREAMING_LANE |
0 | Reserve last N slots streaming-only. |
BEXT_RENDER_DEADLINE_MS |
0 | In-V8 terminate_execution deadline. |
BEXT_V8_EVAL_TIMEOUT_SECS |
30 | In-process eval-thread timeout (≤600). |
BEXT_PRISM_EARLY_SHED |
off | 503 before compile when the queue is full. |
BEXT_PRISM_COMPILE_OFFTHREAD |
off | Compile on spawn_blocking, off the actix thread. |
BEXT_TURBOPACK_COMPILE_SINGLEFLIGHT |
on | Per-content-hash compile dedup. |
BEXT_TURBOPACK_SCOPED_INVALIDATION |
off | Evict only the changed site, not all sites. |
BEXT_TURBOPACK_PIPE_POOL_SIZE |
1 | tsc-rs --pipe worker count. |
Env vars need a full restart. A zero-downtime --swap-only swap inherits the old environment — see Zero-Downtime Upgrades. Use the --legacy-restart path when changing any of the variables above.
Enable BEXT_RENDER_DEADLINE_MS=28000 (2 s under the SIGKILL watchdog) in production. Without it, a CPU-wedged synchronous render can only be recovered by the blunt 30 s SIGKILL — which also kills every request multiplexed onto that worker.
Metrics
Exposed on /__bext/metrics (see monitoring):
| Metric | Meaning |
|---|---|
bext_v8_pool_in_flight |
Current requests in-flight across the pool. |
bext_v8_pool_admission_max |
Configured global cap. |
bext_v8_pool_rejections_total |
Admission rejections → 503s. |
bext_v8_pool_rejections_by_reason_total{reason} |
Breakdown: admission_full, per_worker_cap_filtered, hard_cap_filtered, heap_pressure_filtered, no_parked_slot_filtered. |
bext_v8_pool_wedge_timeouts_total |
Slots SIGKILLed for a wedge timeout. Sustained nonzero = workers wedging. |
bext_v8_pool_quarantined_workers |
Slots currently in cool-off. |
bext_v8_pool_quarantine_fallbacks_total |
Requests routed to a quarantined slot (no healthy slot left). Climbing = systemic stall. |
bext_v8_subprocess_crashes_total / _respawns_total |
Crash + respawn counters; crashes ≫ respawns means the supervisor is rate-limiting. |
bext_v8_in_isolate_dispatch_total{outcome} |
In-isolate loopback-fetch short-circuit outcomes. |
The 2026-06-10 pool-wide freeze
The resilience design above was largely shipped in response to one incident. It is worth reading as a case study in how a shared, undifferentiated pool fails.
What happened (23:47–23:59). All 8 eval workers tripped the 30 s
watchdog within seconds of each other, across completely unrelated
routes (/, a dynamic compare page, a manage page, an ISR example, and
an SSE widget stream). A respawn storm followed — 7 SIGKILLs in 26 s —
and the pool self-healed by 00:00.
Why one slow thing took down everything — five structural causes:
- The pool is global and undifferentiated. An SSE stream pinned an isolate that render traffic also needed.
- The scheduler was blind to wedge state. It ranked workers only
by in-flight count; a CPU-saturated worker looked identical to a
healthy one until its
recv_timeoutfired 30 s later — so the master kept feeding all workers until they tripped together. - The 30 s watchdog is a blunt SIGKILL. One slow request killed the
worker and every request multiplexed onto it. The graceful in-V8
terminate_executionexisted butBEXT_RENDER_DEADLINE_MSwas unset in prod. - The watchdog measures wall-clock, not CPU progress. Under host saturation, legitimate renders stretched past 30 s and became indistinguishable from wedged ones → cluster SIGKILLs → cold respawns reload snapshot+bundle → more CPU pressure → storm.
- A recompile storm was the trigger. A script rewrote two
*.generated.tsmodules; each rewrite immediately evicted the module and its dependents from the registry, and N concurrent requests for the same high-fanout module each cold-compiled independently — serialized through a single tsc-rs pipe worker, on request threads — spiking CPU exactly while an SSE stream held an isolate.
The fixes (all shipped 2026-06-11, each behind its own env flag for independent rollout): the circuit breaker (#2), the in-V8 render deadline made deployable (#3), streaming lanes (#1), compile single-flight + scoped invalidation + off-thread compile + a multi-slot pipe pool (#5), and early shed + proxy max-connection enforcement (load relief). None are on by default; they are enabled per-deployment.
The lesson encoded in the architecture: a shared render pool needs workload isolation, wedge-aware scheduling, graceful per-render kills, and compile-storm dampening — bluntly killing isolates on a wall-clock timeout is a last resort, not a strategy.
Cross-references
- PRISM Rendering Pipeline — what produces the bundles this pool runs.
- V8 Render Engine — the full configuration reference.
- Request Lifecycle — where the render stage sits in the HTTP pipeline.
- Scaling · Monitoring · Troubleshooting — operating the pool.
- Zero-Downtime Upgrades — why env changes need a full restart.