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 siteSubprocessPool 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:

  1. 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.)
  2. 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 by wake_driver() on enqueue / fetch completion) for up to MAX_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 contextsprism_contexts / page_contexts / api_contexts keep an evaluated bundle's context alive (keyed by prism_cache_key(shell, bundle)), so repeat renders of a route skip re-evaluating the bundle.
  • Startup snapshotsnapshot.rs can 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 capBEXT_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 in bext_v8_pool_rejections_total.
  • Per-worker capBEXT_V8_POOL_PER_WORKER_CAP (default 8 non-multiplex / 16 multiplex). Workers at their cap are skipped by the scheduler.
  • Least-in-flight selectionpick_and_claim_worker picks 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.
Warning

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.

Tip

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:

  1. The pool is global and undifferentiated. An SSE stream pinned an isolate that render traffic also needed.
  2. 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_timeout fired 30 s later — so the master kept feeding all workers until they tripped together.
  3. 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_execution existed but BEXT_RENDER_DEADLINE_MS was unset in prod.
  4. 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.
  5. A recompile storm was the trigger. A script rewrote two *.generated.ts modules; 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