JS runtime
How zfb hosts JavaScript execution server-side via an embedded V8 isolate, and why the runtime is fixed by the binary.
zfb renders TSX server-side via an embedded V8 isolate — a Rust-hosted V8 engine running a Hono-style worker bundle built from @takazudo/zfb-runtime.
How the runtime choice was reached
The V8 embedding decision came through a measured evaluation of available JS hosts. Early iterations used an in-process deno_core isolate (ratified after spiking three candidates: deno_core, ssr_rs, and rquickjs), then moved to a Node-hosted miniflare worker to achieve runtime parity with the Cloudflare Workers production target. Tauri distribution ultimately reversed that choice: shipping zfb as a desktop app requires a single binary with no Node.js dependency on end-user machines, and miniflare is a Node package. V8 embedding via deno_core was re-adopted — accepting the binary-size and first-build-time costs — because it is the only path that satisfies the no-Node single-binary constraint. Later, zfb replaced deno_core with its own host that drives the v8 crate directly: #4390 made it the default, and #4395 removed the deno_core host. V8 is still embedded; only the layer between zfb and V8 changed.
The stable production contract throughout has been the bundle shape (export default { fetch }), not the build-time engine. The same bundle that zfb renders at build time deploys unchanged to Cloudflare Workers for runtime SSR.
Why a JS engine at all — and why V8 specifically
zfb uses a JS engine not because TSX requires one, but because evaluating the compiled output does. The distinction matters.
Layer 1 — JSX is just syntax
SWC, oxc, and esbuild all compile JSX to h(...) calls — none of them need a JS engine to do it. Parsing and transforming TSX is a pure syntax operation. So "we support TSX" does not logically imply "we need V8." The question is what happens after the transform.
Layer 2 — The compiled JS still needs to run
Once JSX becomes h(...), something must evaluate that code: walk the call tree to produce a VDOM, then invoke renderToString to produce HTML. Evaluation needs a JS engine somewhere — but not necessarily V8. Two lightweight alternatives exist:
Boa (pure Rust, ~30 s incremental build) — a JS engine written entirely in Rust with no C++ toolchain; tracks the ECMA spec but trails V8 on coverage of newer features.
rquickjs (Rust bindings to QuickJS, ~210 KB binary contribution) — fast to compile, minimal footprint.
During the original JS-host evaluation, both worked well for SSR of a simple, self-contained Preact component. The broader problem was "any frontend dev's TSX with whatever npm packages they imported." When a user added a calendar library, an i18n helper, or a component that used a Proxy-based state store, Boa's ECMA gaps and QuickJS's limited ES2022+ surface could produce silent wrong output or hard panics — failure modes that were extremely hard to diagnose. V8 was the safe bet for arbitrary ecosystem code because it is the same engine Node.js and Chromium run against.
Research is open on both lighter-engine paths — see #344 — feature-gated V8 in the production runtime and #345 — Boa / QuickJS as the SSG-only JS engine.
Layer 3 — Rust templating compiles out the JS entirely
Leptos and Yew take JSX-like syntax (RSX) and compile it to Rust at build time. No JS engine is needed at runtime or build time. The catch: they borrowed the syntax, not the language. There is no import for arbitrary npm packages, no JS closures over runtime values, and no drop-in Preact component from GitHub. A Leptos project is a Rust project — you write Rust, you depend on Rust crates. That breaks the core audience promise: a frontend developer's existing TSX and npm knowledge would not transfer.
The audience tradeoff
zfb's target audience is frontend developers who already know TSX. V8 is the price of admission for that audience — it is what makes "your existing component just works" a true statement rather than a best-effort approximation. The design philosophy section explains this audience-first framing in full.
Pure-Rust SSG (Boa/QuickJS) and no-V8-at-runtime (build-time-only V8) are real future directions — see #344 and #345 — but they are optimizations on top of the default, not the default itself.
What runs today
At zfb build / zfb dev, the Rust orchestrator creates an in-process V8 isolate through the direct V8 host in the zfb-js-host crate. It loads the workerd-shape bundle that @takazudo/zfb-runtime builds with esbuild. Per-route props are passed as JSON; the isolate invokes the bundle's fetch entry with a synthetic Request, collects the Response (HTML), and returns it. The orchestrator writes plain HTML to dist/. No subprocess is spawned; no Node.js is required.
V8 itself is initialized once per process, on the main thread, before the CLI starts Tokio or any host thread (zfb-js-host::process::initialize). Only one host may be alive on a thread at a time.
The same bundle (export default { fetch }) deploys unchanged to Cloudflare Workers for runtime SSR of prerender = false routes. workerd executes it on request. The production path is unaware of which build-time engine produced the bundle.
The need
zfb's renderer compiles a page's TSX through SWC, hands the resulting ESM to a JS host, calls the module's default export, and writes the returned HTML to disk. The host must:
support ESM (
import/export) so pages can import shared components naturally,support top-level
awaitso a page canawait fetch(...)orawait loadCollection(...)at module scope,surface thrown errors with source-accurate locations — the stack-trace line must match the line in the user's TSX file, not an offset into a wrapped script,
evaluate the same module repeatedly across a build of hundreds of pages without leaking memory.
Runtime candidate evaluation
A now-removed spike crate implemented the same RenderHost trait against three candidates, gated behind cargo features so the heavy V8 build was opt-in. The following table is historical context — this evaluation informed the runtime choice.
Candidates
deno_core— V8 wrapped by Deno's reusable core. Ships an ES module loader, an event loop for top-level await, and source-mapped errors out of the box. Chosen by this spike, and used until #4395 replaced it with the direct V8 host.ssr_rs— a thin V8 wrapper specialised for Preact / React SSR. Narrower scope, single-bundle entry-point model.rquickjs— Rust bindings around QuickJS. Small footprint, fast to compile, single-threaded.
Two more were rejected without a measured spike. rusty_v8 is the same V8 we would get through deno_core, only at a lower level — choosing it means re-implementing the loader and isolate plumbing deno_core already gives us. (That cost was later accepted on purpose for the direct V8 host described below, which talks to the v8 crate directly and is now zfb's only runtime.) boa is a pure-Rust ESM implementation; ECMA conformance and source-map fidelity trail V8 by enough that real-world Preact / React SSR hits unsupported corners.
Trade-off matrix
The spike's bench harness loaded five representative scenarios — static page, dynamic route, content collection, "use client", and top-level await — and measured cold start, warm mean, warm p95, and steady-state RSS. Headline numbers from the measurement run:
| Axis | deno_core | ssr_rs | rquickjs |
|---|---|---|---|
| Warm render | 16us | 1.37ms | 106us |
| Cold start | 181us | 5.88ms | 572us |
| Steady-state RSS | 19MB | 317MB | 4.5MB |
| ESM | native | bundle | partial |
| Top-level await | native | bundle | not supported sync |
| Source maps | native | partial | offsets only |
| Build cost | ~3 minutes | similar | ~30 seconds |
The V8-class engines win on the correctness axes; QuickJS wins on footprint and build cost. ssr_rs's default per-render isolate creation dominates everything else and accumulates memory under load.
This build-cost row is not the workspace build time
The ~3 minute figure above is the now-removed spike crate compiling its deno_core dependency in isolation — one crate, one dependency graph. It is not the same measurement as the 15–30 minute cold first cargo build of zfb's whole workspace described in Why Rust, which compiles every workspace crate plus V8 together. The two numbers describe different build scopes.
Constraints zfb imposes
Two project-level invariants shape the choice.
Single isolate per render thread. V8's isolate is pinned to one thread. The renderer treats the host as !Send and runs it on a dedicated thread, exchanging work over a channel. A per-thread guard refuses a second live host on the same thread, and config evaluation creates a fresh host for each call.
The runtime is fixed by the binary, not by config. zfb.config.ts cannot pick its own JS runtime, and there is no CLI flag for it either. The runtime is whatever the zfb binary you installed was compiled with, chosen by Cargo features at build time. That keeps the contract between zfb and user code single-valued — every project built with one binary uses the same runtime. The bootstrap rule lives in crates/.
The boundary
Everything above the host is written against the RenderHost trait in crates/:
#[async_trait(?Send)]
pub trait RenderHost {
async fn execute_module(&mut self, name: &str, source: &str) -> Result<ModuleHandle>;
async fn call_default(&mut self, handle: &ModuleHandle, props: JsonValue) -> Result<String>;
async fn get_export(&mut self, handle: &ModuleHandle, name: &str) -> Result<JsonValue>;
}That is the entire surface the renderer uses. zfb-render, zfb-build, and the framework adapters never name the concrete runtime. The implementation can be swapped without touching call sites — add a new host, re-run the bench, and run the adapter integration tests to confirm byte-identical HTML.
zfb-render exports the direct V8 host, zfb_js_host::native::NativeHost, under the name EmbeddedV8RenderHost, so zfb-build, the dev server and the CLI's thread adapter use that name and never refer to the host crate directly.
Current defaults and experiments
zfb has one JS runtime: the direct V8 host in the zfb-js-host crate. It drives the pinned v8 crate (=147.4.0) directly and implements module loading, the Web API services, dispatch and dev reload itself. It is the default direct-v8 Cargo feature since #4390. #4395 removed the former deno_core-backed host, its embed_v8 feature, and the deno_core, deno_ops, deno_error and serde_v8 crates. Released binaries up to v4.6.0 still use the deno_core host; releases cut after #4395 use the direct host.
Cargo features choose the build when zfb is compiled from source:
# Default: the direct V8 host
cargo build --locked -p zfb --bin zfb
# Slim: no V8
cargo build --locked -p zfb --no-default-features --bin zfbThe ICU 77 data that V8 needs is vendored at
crates/and embedded at compile time. No environment variable or download is needed.zfb- js- host/ icu/ icudtl. dat The slim build has no V8. It evaluates
zfb.config.tsthrough anodesubprocess (so it needsnodeonPATH), andzfb buildandzfb devstop with an error that namesdirect-v8 = off.
Known limitations of the direct host:
Interrupting CPU-bound JavaScript, such as a page stuck in an endless loop, is not supported. The host sets no CPU-time limit and no hard heap limit. When the CLI's threaded host adapter shuts down, it waits up to 10 seconds for its V8 thread and then detaches it; a detached thread is not a clean isolate shutdown.
The qualification harness's watchdog drains a killed child's output on a best-effort basis (#4289). This limits the evidence harness, not the shipped CLI.
A label-triggered, non-required CI lane (direct-v8.yml) records native rows on Linux x64, Linux arm64 and Windows x64. Darwin rows are run by hand on a Mac. The contributor-side details are in prototypes/.
Rolldown bundles pages and islands by default (the rolldown-prototype feature is in the default feature set). Set ZFB_BUNDLER=esbuild to use the embedded esbuild for those bundles instead; esbuild also still compiles zfb.config.ts and plugins. The direct-v8-rolldown lane builds Rolldown together with direct V8.
zfb preview does not rebuild: it serves built output, or delegates to Wrangler with the Cloudflare adapter. It does not generally render pages through the embedded V8 host.