Guides

Hot-patching a running app

Status: built (soda dev), 2026-10-17, after a spike on 2026-10-16. A change to a page, a layout, a component or an action reaches the open page in about a second, without reloading it and without losing anything it holds: in a Soda instance running in a service worker (the local-first backend), or in a server as it runs (below). This is how, what it takes, and what soda dev does with it (framework.md §9).

What was shown#

Subsecond (from Dioxus) patches a running Rust program: calls made through its jump table go to the newest version of a function. Dioxus's CLI, dx, builds the program once (a "fat" build that keeps every symbol), then on each save recompiles only the changed crate, links the changed functions into a patch and sends the program a jump table naming it. On the web, the patch is a small WebAssembly module loaded into the running module's memory and function table.

Soda's instance isn't in a page but in a service worker, which Subsecond doesn't expect. With the template (the checklist, on Soda) built by dx and run in a module service worker:

ChangePatch built by dxOn the open pageReloaded
A toy crate's render function0.2 to 0.3 s0.23 sno
The template's start screen (intro() in routes/layout.rs), first patch2.6 s2.8 sno
The same, every patch after0.82 to 0.84 s1.3 sno
An action (add in routes/actions.rs), in an open listabout as fastthe next item added used the new codeno

Measured on an M-series laptop, Chromium, dx 0.8.0-alpha.3 and Subsecond 0.8.0-alpha.3, 2026-10-16. The open list, its history and the page's address were all still there after every patch: they live in the instance's memory and the browser's storage, and a patch replaces code, not data. Every open tab is served by the one worker, so one patch reaches them all.

What it takes#

  1. dx 0.8. dx 0.7.10 (the current release) can't read what wasm-bindgen 0.2.129 writes: it imports an exception tag (__wbindgen_jstag), and dx 0.7's parser has exceptions turned off. dx 0.8.0-alpha.3 reads it. The SDK pins dx's exact version, as it pins Tailwind's.
  2. The app is a program. dx builds a binary (or an example), never a library, and patches only the crate it builds: a binary depending on the app's library wouldn't see a change to it. So a Soda app is src/main.rs (soda_local::app! writes its main), and its release build runs cargo build and the pinned wasm-bindgen CLI itself (scripts/wasm-bindgen.sh), since wasm-pack builds only libraries.
  3. A module service worker in development. dx's glue is an ES module (wasm-bindgen's web target), so the development worker imports it, registered with type: 'module'. Release builds keep today's worker.
  4. A window for Subsecond. Subsecond fetches a patch through web_sys::window(), which a worker hasn't got. A worker's own global has the same fetch, so in development the worker lets it pass for one: globalThis.Window ??= ServiceWorkerGlobalScope.
  5. The view and the routes are called through the jump table. soda_local::hot calls the app's Site::view and Site::route through subsecond::HotFn with the SDK's hot feature (which soda dev turns on), in the browser, and plainly everywhere else: a release build has no Subsecond in it at all. Once a patch is in, the instance bumps its version, so every open page's stream draws it again from the new code.
  6. The latest patch, for a worker that starts afresh. A browser stops an idle worker, and a new one starts from the fat build. dx doesn't send the latest patch to a client that connects later (0.7.10 and 0.8.0-alpha.3 alike), so soda dev keeps it, and a starting worker applies it before answering anything.
  7. Routes read when the crate compiles. dx's patch build runs rustc alone, so build.rs never runs again: a new route folder was patched in, but the router didn't know it. The router has to come from a procedural macro reading src/routes (soda_local::app!), which every compile, patches included, expands afresh; build.rs stays only to make Cargo rebuild when the folder changes.
  8. One unsafe call. Applying a patch is unsafe, and the SDK forbids unsafe code. The call is compiled only with the hot feature, in the browser; everywhere else the SDK still forbids it.

What Subsecond does that isn't obvious#

  • A closure the size of a pointer is taken for a function pointer, and never patched. On wasm32 a pointer is four bytes, so subsecond::call(|| page(&path)) (a closure holding one reference) is looked up as if it were a function pointer, isn't found, and runs the old code. A function item (zero-sized) is looked up properly: HotFn::current(page).call((path,)).
  • An async function's body is its future's poll. Patching the function that makes a future changes only the making; the caller then polls it with the old code, compiled into the build it came from. The future has to be boxed inside the hot function, so its poll is found through the box, from the patch.
  • A new file is no change, to dx. It watches what the crate was compiled from, and a new route isn't, yet (Soda's router finds it as it compiles). soda dev makes it one (below).
  • The first patch compiled the crate afresh. dx compiles each patch of the app's crate position-independent (-Crelocation-model=pic), and its whole build didn't: a setting the compiler's incremental cache depends on, so the first patch after every whole build threw the cache away (2.2 s), and only the patches after it reused theirs (0.7 s). soda dev has dx build the app's crate the same way whole (--rustc-args, which reaches only that crate), and the first patch is as quick as the rest.
  • Debug info is most of what dx keeps. The fat build keeps every relocation, to link patches against, and debug info's were 21 MB of the template's 48. soda dev builds without it (a panic still says where it happened, and a stack trace names its functions): the fat build is 22 MB, a whole build takes 3.9 s instead of 8.6, and a patch's compile half as long.
  • Patches are never freed. Each is loaded into new memory and new table slots. The template's are 5.9 MB each (the whole crate, in a debug build: 3.6 MB of code and 2.1 MB of function names), so a long session grows the worker. soda dev restarts the worker (which then takes the fat build and the latest patch only) once its patches pass a size.
  • Layouts can't change. A patch can change what code does, not the shape of data that's already in memory: a changed struct or enum in the running instance would be read with the old layout. Soda's instance rebuilds its whole state from the signed log whenever it starts, so soda dev restarts it instead of patching when a type changes (it compares the crate's type definitions before and after the save), which costs a reload and nothing else.
  • The low-level tool isn't there yet. dx tools hotpatch, which patches from a saved fat build without dx's server, fails in 0.7.10 (its link step gets no input files). dx serve is the engine, and soda dev drives it.

How soda dev uses it#

soda dev (the SDK's CLI, cli/) runs dx beside its own server, and is what the browser talks to. npm run test:dev (the SDK's tests/dev.test.mjs) runs every case below against the template.

Measured from saving the file to the open page showing it, on an M-series laptop with the local store and other servers running (2026-10-17, three runs of npm run test:dev):

You changeWhat happensMeasured in the template
A page, a layout, a component or an actiondx patches it into the running instance; every open page draws again, without reloading0.9 to 1.5 s, the first after a whole build too (dx itself takes 0.65 to 0.8 s of it)
A style, or a class in a viewTailwind (kept watching) compiles it, and the open pages swap the stylesheet and the components' shared sheet in place0.16 to 0.3 s
A new file (a new route), or one removedpatched in like an edit (dx is told: see below), the page not reloaded, and from then on its edits are patched1.2 to 1.7 s, to its page open
A struct, an enum or a union that was there beforebuilt whole (a patch can't change the shape of data in memory), and the pages reload5.2 to 6.2 s
The shell (web/app.html)patched in (it's compiled into the instance, and read through the jump table), and the pages reload into it once the worker says it's landed0.9 s, to the reloaded page
Cargo.toml, build.rsbuilt whole, and the pages reloadas above
Another file in web/ (a component's script, say)copied in, and the pages reloadat once
A file that doesn't parsesaid over the page, with where (dx skips such a file, so soda dev parses it itself), until it does; a page opened meanwhile is told too0.2 s
A file that parses but doesn't buildover the page in the compiler's words, then with where and the code around it (cargo check, as dx says nothing more), until it builds or is put back0.3 s, then 1.2 s more
Code that panicsthe instance stops (as it does in a release build), and the panic, with where it happened, is over the page; the patch that fixes it starts a fresh instance with itabout a second

In a bigger app the patch is what its crate takes to compile: Soda IR (7,700 lines in one crate, its model 2,500 of them) patches its layout in 6.1 s the first time, then 1.6 to 1.8 s, the page kept each time. dx compiles the whole crate a patch is in, so the model in a crate of its own should bring a page's patch nearer the template's (not yet tried).

Nothing a page holds is lost in any of it: a reload opens the same address, and the data is in the browser. How it fits together:

  • dx builds the instance (dx serve --web --hot-patch --features soda-local/hot, the pinned dx from scripts/dx.sh), and its patches go to the worker over soda dev's own socket. soda dev keeps the latest, for a worker that starts afresh.
  • The development worker is the release one (sw.js) with web/soda-dev-worker.js ahead of it, as a module: it loads dx's glue, applies the latest patch before answering anything, then each as it comes, one at a time. It caches nothing, since every file is the one just saved.
  • soda-local.js, served by soda dev saying so, registers that worker, and soda.js loads web/soda-dev.js, which swaps the stylesheet, reloads when told, and shows what went wrong over the page (on either backend).
  • soda dev compares each saved file's types (by name: a new type is only new code) to tell a change a patch can carry from one it can't.
  • dx patches only what the crate was compiled from, so it doesn't see a file that's new (a route the router will read) until something it does see changes. soda dev keeps the list of the app's Rust files in .soda/files (.soda keeps itself out of git, as SvelteKit's .svelte-kit is kept), and Soda's router, under the hot feature, is compiled from it: a file added or removed rewrites the list, and dx patches the router afresh, the new route in it. The new file is then among what the crate is compiled from, so dx patches its edits by itself.
  • dx compares a saved file with the app as it last built it whole, so a file put back as it was then is no change to it: a panic taken out of a page edited for the first time (what's running is the patch since), or a mistake put back (it doesn't build). soda dev keeps each file's syntax as of the last whole build, and asks dx for a build then (writing .soda/files again is a change dx sees); what went wrong is cleared only by a build that succeeds.
  • While a file doesn't parse, soda dev's word for it (with where) stays over the page until that file parses, whatever else changes meanwhile (the stylesheet's outputs, rewritten a moment after a save): a build dx makes meanwhile fails for the same reason, in fewer words.
  • Tailwind's entry (target/soda/dev/styles/entry.css) is in a folder of its own: Tailwind rebuilds for any change in its entry's folder, and what's written after each build (the class map, the components' sheet) would set it off again, endlessly.
  • With --no-hot, or without dx, Cargo builds the instance in the dev profile and the pages reload into it (0.8 s for the build after a one-line change).

On a server#

The same, with dx running the program instead of building for a browser (tests/dev-server.test.mjs runs every case on the guestbook):

  • soda dev runs dx serve --server --hot-patch --features soda-server/hot, and gives the program where to serve (SODA_ADDR, a free port), the site it built (SODA_DIST) and the shell to read from disk (SODA_SHELL). dx launches it after each whole build, and tells it where dx is.
  • The program connects to dx (soda-server's hot module), saying where its main is loaded, which dx links each patch against; it applies each patch meant for it (a native library, loaded beside it) and draws every open page again. Its drawing, its actions and its downloads are called through Subsecond's jump table, each a future (a page may be awaited) boxed inside its hot function, as an action is on the local backend.
  • In front, soda dev keeps the pages' socket and passes everything else to the program, streams and all, waiting while it builds or starts again.
  • A page that panics is caught by the server and drawn in its place, the panic's words and where (a panic hook keeps where, in development only; in production a page says only that something went wrong); an action that panics is refused in its words.
  • A whole build (a type's shape, Cargo.toml) starts the program again, and the pages reload into it: what it keeps in its data folder (a database) is still there, and so is every session (taken back from its sealed cookie); only what it kept in memory starts again. soda dev stops dx as Ctrl-C does, so dx stops the program it started, rather than leaving it serving.
You changeWhat happensMeasured on the guestbook
A page, a layout, a component or an actionpatched into the running server; every open page drawn again on its stream, the book, the sessions and the pages all kept0.6 to 0.8 s
A new routepatched in like an edit1.5 s, to its page open
A styleswapped in place0.3 s
The shellthe pages reload (the server reads it from disk under soda dev)0.16 s
A type's shapethe server built whole and started again, the pages reloaded: what's in its database (and every session) still there4.9 s (with sqlx)
A panic in a pagedrawn where the page was, with where; fixed, the page is drawn again1.5 s to draw it again

Open: module service workers in Safari (development only; release builds don't need them).

On a static site#

A static site (soda-static) is drawn as it's built, but soda dev runs its program as a server, just as it runs a server app's: dx builds it with soda-static/hot, runs it, and patches each change in, and every open page (each holds a stream while it's worked on, which the build never writes) is drawn again in place. What the site is made of changes as often as its code: what its App::WATCH names is looked at a few times a second, and a change loads it again and draws the open pages again. Measured on the template, a handbook of Markdown pages:

ChangedWhat happensUntil the open page shows it
A page, a layout or a componentpatched into the running program; every open page drawn again on its stream0.5 to 0.7 s
A chapter's Markdown (in WATCH)the site's state loaded again; every open page drawn again0.3 s

soda build draws every page from the code as it is, so there's nothing to patch there.

This page is docs/hot-patching.md in the SDK, built into this site as it is.

Esc