Dev mode lifecycle
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/ 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/trueforces lazy,0/falseforces eager). Takes precedence overZFB_DEV_EAGERwhen both are set.
Both are read once at boot; changing them requires a dev-server restart. They are also documented in zfb dev --help.
Pages re-rendered. Before the renderer is called,
BuildContext::reload_rendererfires 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 anArcto 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 renderedRenderedPage.htmlis 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.)CSS pipeline. When
rerun_cssis true, Tailwind v4 + PostCSS run. If the CSS output is byte-identical to the previous tick, noCssevent is emitted.Islands re-bundle. When
rerun_islandsis true, the esbuild Go binary subprocess is invoked. It bundles every"use client"component and writes a single combined module to a stable filename —dist/(theassets/ islands. js STABLE_ISLANDS_FILENAMEconstant incrates/, re-exported and consumed by the islands bundler). No content hash in the filename; see Why filenames stay stable in dev.zfb- types/ src/ asset_ urls. rs Build outcome broadcast. The pipeline returns a
BuildOutcomestruct.outcome_to_events()incrates/inspects the outcome and maps it tozfb- server/ src/ livereload. rs ReloadEventvalues that are broadcast over the SSE channel at/. Every browser tab that has your site open is subscribed to that channel and reacts immediately._ _ zfb/ reload
The three SSE event types
| Outcome trigger | Event | Browser behaviour |
|---|---|---|
pages_written non-empty or pages_stale non-empty | Page | Full location.reload() |
css_changed | Css | Hot-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
(/). The client script at / 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 (/) 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/ — 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/.)
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.
Related
Build pipeline — the full pipeline from CLI to
dist/, and how dev mode layers on top of itIncremental rebuild — the dependency graph and
DirtySetthat limit rebuilds to affected pagesIslands — how
"use client"opts a component into the islands bundleSSR and Cloudflare Bindings — dev-side SSR parity: how
prerender = falseroutes are served through the same V8 renderer