zfb
GitHub repository

Type to search...

to open search from anywhere

Dev mode lifecycle

Created Jun 24, 2026Takeshi Takatsudo

What happens between hitting save on a .tsx file and seeing the change in the browser — watcher, rebundle, SSR refresh, and the three SSE event types.

What this page covers

The save-to-pixel loop for zfb dev: how the watcher detects your change, how the orchestrator decides what to rebuild, and how three SSE event types let the browser react without an unnecessary full page reload. For the overall build step order readBuild pipeline; for how the dependency graph limits rebuilds to affected pages read Incremental rebuild.

For prerender = false SSR routes: each EDIT tick re-bundles and swaps a fresh V8 host into the shared renderer mutex (see step 1 below), so SSR requests serve the updated code immediately after the tick completes — no restart needed. One limitation remains: an SSR-only edit produces no SSG HTML writes, so no Page SSE event is emitted and the open browser tab does not auto-refresh. Manually reload the tab to see the updated SSR output. For context on the dev-side SSR path, seeSSR and Cloudflare Bindings.

You save a .tsx file. Then what?

The moment you save, the operating system fires a filesystem event on the file's path. crates/zfb-watcher is watching every relevant directory — pages/, components/, content/, layouts/, styles/, data/, and the two config files zfb.config.json and zfb.config.ts — plus each configured collection's path when it lives outside those default roots — via the notify crate. (See DEFAULT_WATCH_ROOTS and derive_watch_roots in crates/zfb/src/commands/dev.rs for the canonical list.)

Note that public/ is not a watch root. Static assets are served live from disk on each request (see Static Assets — dev / prod parity), so editing a file under public/ never produces a watcher event and never triggers a livereload — the dev server simply serves the new bytes on the next request. Keeping public/ out of the watched set is also what lets boot stay fast regardless of how large your asset tree is (see Boot is bind-first below).

Editor saves are rarely a single clean event. vim renames a swap file over the original; VS Code emits several metadata-then-data events in quick succession; a git checkout fires hundreds at once. The watcher's debouncer coalesces everything within a 50ms quiet window into a single Change { path, kind } value. The kind field is one of ChangeKind::Created, ChangeKind::Modified, or ChangeKind::Removed; any event kind the OS cannot classify collapses to Modified so that a real change is never silently dropped.

Once the burst settles and the debounce window closes, the build orchestrator (crates/zfb-build) receives the change. It calls into crates/zfb-graph with the changed path and gets back a DirtySet — either All (global files like zfb.config.ts) or Specific(set_of_page_ids) for the pages that actually import the changed file. Pages outside the dirty set are not touched. (The full dependency tracking story lives in Incremental rebuild.)

The orchestrator assembles a RebuildPlan from the dirty set. If the changed path is inside an islands root (e.g. components/), the plan's rerun_islands flag is set. If a CSS source changed, rerun_css is set. Then the DevAssetPipeline::apply() method runs, in this order:

Lazy dev rendering (the default)

By default, the "pages re-rendered" step below is lazy: a tick eagerly re-renders only the edited content entry's own routes and marks every other affected route stale. A stale route re-renders through the live V8 host on its first request (GET or HEAD) — the request blocks until the fresh bytes are written, so the response is never stale. The initial render at zfb devboot always stays eager, so every route exists on disk from the start.

Two environment switches control this:

  • ZFB_DEV_EAGER=1 — escape hatch: restore fully eager rendering (every affected route re-renders on every tick, the pre-lazy behaviour).

  • ZFB_LAZY_DEV_RENDER=0|1 — precise override (1/true forces lazy,0/false forces eager). Takes precedence over ZFB_DEV_EAGER when both are set.

Both are read once at boot; changing them requires a dev-server restart. They are also documented in zfb dev --help.

  1. Pages re-rendered. Before the renderer is called, BuildContext::reload_renderer fires for EDIT ticks (i.e. ChangeKind::Modified — what VS Code and most editors emit). It re-bundles with a fresh content snapshot from disk, starts a new embedded V8 host against the rebuilt bundle, and swaps it into the shared renderer mutex (start-before-swap — the old host is shut down only after the swap succeeds, so a bundle error leaves the previous renderer serving). The render callback and the SSR adapter both hold an Arc to the same mutex, so all subsequent requests in this tick render through the new host. The orchestrator then calls the renderer for each page in the dirty set; each rendered RenderedPage.html is compared byte-for-byte against the last-known output. If the bytes are identical (a pure refactor that produced no semantic HTML change), no file is written and no reload signal is sent for that page. (Boot's initial render and watch-ADD discovery already carry a fresh bundle, so the reload step is skipped on those paths — each tick re-bundles at most once.)

  2. CSS pipeline. When rerun_css is true, Tailwind v4 + PostCSS run. If the CSS output is byte-identical to the previous tick, no Css event is emitted.

  3. Islands re-bundle. When rerun_islands is true, the esbuild Go binary subprocess is invoked. It bundles every "use client" component and writes a single combined module to a stable filenamedist/assets/islands.js (the STABLE_ISLANDS_FILENAME constant in crates/zfb-types/src/asset_urls.rs, re-exported and consumed by the islands bundler). No content hash in the filename; see Why filenames stay stable in dev.

  4. Build outcome broadcast. The pipeline returns a BuildOutcome struct. outcome_to_events() in crates/zfb-server/src/livereload.rs inspects the outcome and maps it to ReloadEvent values that are broadcast over the SSE channel at /__zfb/reload. Every browser tab that has your site open is subscribed to that channel and reacts immediately.

The three SSE event types

Outcome triggerEventBrowser behaviour
pages_written non-empty or pages_stale non-emptyPageFull location.reload()
css_changedCssHot-swap every <link rel="stylesheet"> — appends ?v=<timestamp> to bust the browser cache without reloading the document
islands_bundle.is_some()Islands { component, bundle_url }Dynamic import() of the new bundle URL (with a cache-busting ?v=<timestamp>). The newly-imported module runs its hydration, which by default re-mounts every [data-zfb-island] element on the current page — no document reload

When multiple events fire in the same tick, the server emits every applicable event, and every connected tab is subscribed to the same SSE channel and receives all of them. The browser processes events in arrival order. On receiving a Page event, each tab calls location.reload(), which discards the current document — this makes any Css or Islands events emitted in the same tick moot for that tab. The "only this tab gets a full reload" pattern does not exist here; the in-place Css and Islands swaps are moot for every subscribed tab in that tick, not just the active one.

One detail on Islands: in dev mode today, BuildContext::run_islands reports components: Vec::new() to outcome_to_events() because the build-side payload does not currently surface per-island names. The server therefore emits a single Islands event with component: "" and the stable bundle URL (/assets/islands.js). The client script at /__zfb/livereload.js reads only bundleUrl, appends a fresh timestamp (?v=<timestamp>), and dynamic-imports the result. The imported bundle's top-level hydration code then runs and walks every [data-zfb-island] on the page — so a single islands tick re-hydrates the whole page, not a single component, by default.

Targeted re-hydration is opt-in. When the client detects a user-provided window.__zfbIslandsReload(component, swapUrl) function, it delegates the import to that hook instead of doing a plain dynamic import. Applications that want to preserve scroll position or component state across an islands hot-swap install this hook and decide for themselves which components to remount. Without the hook, the default page-wide re-hydration is what runs.

Why filenames stay stable in dev

Production builds use content-hashed asset URLs (/assets/islands-abc12345.js) so that deployed CDN responses can be cached indefinitely and a new deploy's changed assets get fresh URLs. The hash is determined by file content; it changes every time the bundle changes.

Dev mode deliberately skips the content hash. The output lands at dist/assets/islands.js — the same URL every tick. This is the URL-contract guarantee that makes SSE-driven hot-swap work: when the browser receives an Islands event, it knows the new bundle is always reachable at the same base URL. Changing the URL on every rebuild would force a full page reload because the browser would have no cached reference to swap from.

Content hashing is the production pipeline's responsibility. In dev the stable name is correct by design, not an oversight.

What this means in practice

Three typical edit scenarios, and which event fires:

Edit a .tsx island component body only. The watcher fires on the component file. The dependency graph marks pages that consume it dirty; the orchestrator re-renders affected pages first, then re-bundles islands. If the rendered HTML changed, a Page event fires and the browser reloads. If the HTML is byte-identical (e.g. you only changed client-side state logic that never reaches the server render), only an Islands event fires — the new bundle is dynamic-imported and its hydration runs, re-mounting every island on the page. To preserve scroll position or per-component state across that swap, install a window.__zfbIslandsReload hook (see The three SSE event types).

Edit a page file that consumes an island. Both page and island are dirty. The orchestrator re-renders the page; the HTML almost certainly changed; a Page event fires. The browser gets a fresh full-page load with up-to-date server-rendered HTML. Any concurrent Islands events for that tab are mooted by the reload.

Edit a CSS file only. No pages are dirty, no islands re-bundle. The CSS pipeline runs; if the output changed, a Css event fires. The browser swaps the stylesheet in place — no document reload, your scroll position and any client-side state are preserved.

Boot is bind-first

Everything above describes the steady-state edit loop. Boot itself is ordered to get the server reachable as fast as possible: zfb dev binds the TCP listener before it does any project-wide work. The manifest-digest walk, the persisted dependency-graph load, the graph seed, and the initial render all run on a background task that is spawned only after the bind succeeds. (See the bind-first restructure around TcpListener::bind in crates/zfb/src/commands/dev.rs.)

The cost this removes is compute_manifest_digest's WalkDir + metadata() walk over the watched tree. Running it before the bind made the port's reachability scale with the size of the watched tree — a project with a large static-asset directory or a symlinked tree could hang for a noticeable beat before the server accepted its first connection. Binding first, then walking on a background task, makes the server start accepting connections in constant time regardless of how big that tree is.

What this win is — and isn't

The boot improvement is independence from static-asset / watched-tree size. It removed a pre-bind walk; it did not make the actual build cheaper. Initial page rendering, CSS bundling, and the islands bundle still scale with the number of pages, islands, and source files in your project — that work still happens, just on the background task after the listener is up. A request that arrives for a route the boot render has not reached yet still blocks until that route is rendered; what you gain is that the listen socket is up immediately, not that the first render is free.

This change pairs with public/ no longer being a watch root (above): together they mean a large public/ tree neither slows startup nor feeds the watcher.

  • Build pipeline — the full pipeline from CLI to dist/, and how dev mode layers on top of it

  • Incremental rebuild — the dependency graph and DirtySet that limit rebuilds to affected pages

  • Islands — how "use client" opts a component into the islands bundle

  • SSR and Cloudflare Bindings — dev-side SSR parity: how prerender = false routes are served through the same V8 renderer

Revision History

Takeshi TakatsudoCreated: 2026-06-25T05:17:25+09:00Updated: 2026-06-25T05:17:25+09:00

AI Assistant

Ask a question about the documentation.