engineering

Kilobytes, Not Megabytes: Resumability and Real HMR in bext

The dominant client-side model is hydration: the server renders HTML, then the browser downloads the framework plus your component code, re-executes the whole component tree to rebuild its in-memory state, and only then becomes interactive. You pay for the framework twice — once to render on the server, once to re-run on the client — and the user waits for code that has already done its job once.

bext islands now do the opposite. They resume.

What resumable means here

A resumable island runs once, on the server. We serialize the signal values and a marker→signal map into the HTML, and the client reconstructs the reactive graph from those values without ever calling the component function again. The handlers are lifted at compile time into a tiny factory map; on interaction we resolve one handler and run it. The component body never executes in the browser.

flowchart LR
  A["component runs<br/>(server, once)"] --> B["serialize signal values<br/>+ marker map → HTML"]
  B --> C["client reconstructs signals<br/>(no component re-run)"]
  C --> D["interaction → resolve<br/>one lifted handler"]

This is the Qwik model. We're not claiming to have invented it — we're reporting that bext has it, end to end, opt-in via a "use resumable" directive, with a measured cost worth writing down.

The numbers

Every figure below is measured first-party on demo.bext.dev (gzipped over the wire, curl … | gzip -c | wc -c). The competitor figures are public production sizes, cited as such — not something we benchmarked, because a framework's runtime size is a fixed published fact.

What a client pays for an interactive countergzipped
bext — lazy island, before interaction0 B
bext — first eager island + shared runtime (first visit)~3.5 KB
bext — each additional island (runtime already cached)~0.6 KB
Solid (solid-js runtime)~7 KB
React + ReactDOM (framework floor, before any component)~45 KB

The per-island bundle is 589 bytes gzipped (ResumableCounter.js, measured live). The shared resume runtime is 2.97 KB gzipped (9.1 KB minified raw), fetched once and cached across every island and every page — so a second island on the page costs only its own 0.6 KB. Server TTFB on a cached route is **18 ms** (Rust render + ISR), measured over three warm requests.

A few honest caveats, because numbers without them are marketing:

  • This is the framework floor, not a site's total. A heavy site — including our own demo gallery, with its syntax highlighting and embedded editors — ships its own chrome on top. The point isn't that every bext page is tiny; it's that the framework doesn't force a heavy baseline on you.
  • The shared runtime ships minified at 9.1 KB raw, 2.97 KB gzipped over the wire. (We added an AST-safe JS minifier this cycle; CSS was already minified.) It's fetched once and cached, so it's a one-time cost across every island and every page on the site.
  • "0 B before interaction" is the lazy mode. Eager islands resume on load and pay their bundle plus the shared runtime on first visit.

How small the bundle got

Before this cycle a resumable island bundle was 123 KB — because it inlined the whole signals framework, including ~30% of dead hydrate.ts machinery the resume path never touches, the JSX adapter, and the component's own render function (which is server-only and never runs on the client). We split it: a shared runtime carries signal/computed/effect + the resume logic, and each island ships only its self-contained __resume factory map.

ResumableSum.js: 123,498 → 1,522 bytes (raw, measured live). Same behavior.

What resumes

Not just counters. The dialect covers what real components need:

  • signal() and computed() — including computed-of-computed graphs (a pricing calculator: subtotal → discount → tax → total, each derived from the last, re-derived live on the client).
  • Multi-signal reactive reads{a.value + b.value}, conditionals, items.value.length.
  • TypeScript-typed handlers(e: Event) => (e.target as HTMLInputElement).value is type-stripped to valid JS for the inlined factory.
  • Async data — an SSR loader fetches the initial data into a signal (it resumes), and an async () => { await fetch() } refetch handler lifts verbatim and runs on the client. No createQuery bag required.

All of it is live and puppeteer-verified at demo.bext.dev/examples/resumability and the pages around it.

Lazy: zero JS until you touch it

Add { lazy: true } and the island's bundle is not downloaded on page load at all. A ~1 KB qwikloader-style runtime watches for the first interaction, fetches the bundle, resumes the state, and replays the event. Five interactive widgets on a page → zero bytes of island JS until a visitor actually uses one. We verified it: load the lazy gallery, and nothing under /islands/ is requested until you click — and the widget you never touch is never downloaded.

Real HMR — the hard half, done

Hot reload usually means "edit, full reload, lose all your state." We shipped the part that's actually hard: keeping state across a code edit, with no full reload.

window.__bextHmr.swapIsland(name) snapshots an island's live signal values, re-fetches its freshly re-rendered markup, replaces the element, loads the recompiled bundle, and resumes the new code with the old state written back. The old element is discarded, so its effects and listeners go with it — no disposer bookkeeping.

We verified it with a real mid-flight edit: bump a counter to 8, then change its handler from +1 to +2 and recompile, then hot-swap. The counter stays at 8 (state preserved), the next click yields 10 (the new +2 code is live), and the document never reloaded.

Code
after 3 clicks:   Count: 8
after hot-swap:   Count: 8     ← state preserved
document loads:   1            ← no full reload
click after swap: Count: 10    ← new +2 code is live

What we deliberately left out

Server-side SSR module hot-swap — rewriting every module's import emit into live-bound __bextRebind thunks so a changed page module swaps in the warm V8 context without re-eval. Our own implementation plan judges it marginal: bext already recompiles incrementally and a warm-context re-eval costs a few milliseconds. And it carries the highest blast radius of any change in the codebase — every module's emit, across every site we host. The honest engineering call was not to ship it for a few milliseconds of dev-loop savings. We'd rather tell you that than pad a feature list.

Try it