zfb

Type to search...

to open search from anywhere

JS runtime

CreatedJun 1, 2026Takeshi Takatsudo

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.

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.

Both work well for SSR of a simple, self-contained Preact component. The problem is “any frontend dev’s TSX with whatever npm packages they imported.” The moment a user adds a calendar library, an i18n helper, or a component that uses a Proxy-based state store, Boa’s ECMA gaps and QuickJS’s limited ES2022+ surface start producing silent wrong output or hard panics — failure modes that are extremely hard to diagnose. V8 is 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 / zfb preview, the Rust orchestrator creates an in-process V8 isolate via deno_core. 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.

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 await so a page can await fetch(...) or await 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

The spike crate (crates/zfb-runtime-spike/) 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. The current choice.
  • 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. 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:

Axisdeno_coressr_rsrquickjs
Warm render16us1.37ms106us
Cold start181us5.88ms572us
Steady-state RSS19MB317MB4.5MB
ESMnativebundlepartial
Top-level awaitnativebundlenot supported sync
Source mapsnativepartialoffsets only
Build cost~3 minutessimilar~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.

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.

The runtime is fixed by the binary, not by config. zfb.config.ts cannot pick its own JS runtime. The runtime is whatever the zfb binary you installed was compiled with. 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/zfb/src/config.rs.

The boundary

Everything above the host is written against the RenderHost trait in crates/zfb-render/src/render_host.rs:

pub trait RenderHost {
    fn execute_module(&mut self, name: &str, source: &str) -> Result<ModuleHandle>;
    fn call_default(&mut self, handle: &ModuleHandle, props: JsonValue) -> Result<String>;
    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.

Revision History