The Featherweight Pattern: Cross-Device State Without a Backend
A static site, a low-latency key-value store at the edge, and local-first client hydration make a whole class of web apps faster and lighter than reaching for a backend. Where the pattern fits, how it works, and the JSON-vs-binary numbers on whether the payload should ever be binary.
It started with a character sheet.
My brother and I have been rolling dice together since we were kids: first edition AD&D, the version with the attack matrices and the saving-throw tables you had to memorize. I built him a digital sheet: dice that actually tumble, hit points you drag to spend, spell slots you tap to burn. A toy. A gift.
Then he said the thing that turned a toy into a question: “Can I take damage on my laptop and have it show up on my phone?”
The sheet lived on a static site. Static sites, famously, have no server to remember anything. The textbook answer is “rewrite it as a real app”: stand up a backend, reach for a framework, ship a single-page application. For a hit-point counter, that’s absurd. So I didn’t. And the not-doing turned into a small thesis about how we build for the browser that I think is worth writing down.
The problem nobody names
Most web apps are built one of two ways.
The heavy way (SPA). React, Vue, the whole apparatus. You ship a JavaScript runtime to every visitor, hydrate a component tree, manage a store. It’s powerful and it’s the default, and for a lot of what it’s used for, it’s a cargo plane delivering a sandwich. The runtime, the build, the hydration cost, the dependency churn: all real, all paid on every page.
The thin way (HTML over the wire). htmx, Hotwire/Turbo, Phoenix LiveView. Push the work back to the server; send HTML fragments down the socket. Beautiful for a lot of cases, but now every interaction is a round-trip, and you need a live server doing the thinking. The client stays thin on purpose, which means the network is in the loop for things that don’t need it.
There’s a third way, and it doesn’t have a tidy name, so I’ll give it one: featherweight.
Ship the app as a static asset (it’s just files on a CDN). Keep the state on the client, local-first. Sync it through a low-latency edge KV store, a key-value namespace that knows nothing about your data, only to move that state between devices. No backend. No build step. No round-trip in the hot path.
The character sheet is featherweight. Damage, spell slots, conditions, notes: they all live in the browser, instant and offline. A tiny Cloudflare Pages Function backed by a KV namespace does exactly one thing: hand back a blob of bytes when asked, store one when told. It has no idea what a hit point is. My brother takes 6 damage on his laptop; the laptop writes the blob; his phone reads it next time it opens the sheet. That’s the whole backend. It cost nothing and it deploys with the static site.
Where this sits in the world
None of the pieces are new. That’s the point. Featherweight is a composition of ideas that are each well understood:
- Local-first. The Ink & Switch essay is the canon: your data lives on your device, the cloud is a sync convenience, not the source of truth. The heavyweight version of this uses CRDTs (Automerge, Yjs) and sync engines (ElectricSQL, Replicache, Zero). Featherweight is local-first minus the CRDT: single writer, last-write-wins, which is all a personal sheet needs.
- State as a value, not a process. A backend-with-a-database treats your state as a living process you must keep correct. A blob you hydrate treats it as a value: copyable, diffable, cacheable, shippable. Undo, snapshots, “send someone your exact state” fall out for free.
- Zero-copy serialization. Apache Arrow, FlatBuffers, Cap’n Proto. “Ship bytes, read fields in place, never parse.” More on this below, because it’s where the fun benchmark is.
- Game netcode and market-data feeds. Decades of prior art on shipping compact binary state to many clients fast.
The edge is what makes it practical now. A static CDN was always fast at delivery; what changed is that the same platform (Cloudflare Pages/Workers, and friends) gives you a tiny stateful pipe right next to the assets. Static delivery + a low-latency edge store + a smart client. Featherweight.
The data-encoding question (and the honest answer)
Here’s where I almost fooled myself.
Once you’re hydrating state on the client, the engineer’s reflex kicks in: should the payload be binary instead of JSON? Binary is faster, right? It’s the kind of question that feels rigorous and is actually the wrong one. So I built a benchmark to make myself answer it with numbers instead of vibes.
The setup: a spell-compendium-shaped dataset (a few numbers, a couple of strings, a text-heavy description per record: exactly the kind of reference data you’d hydrate). Two encodings: plain JSON, and a hand-rolled zero-copy binary you read field-by-field with a DataView, no parse step. Measured two things across dataset sizes, decode timed in real headless Chrome:
| records | JSON decode | Binary decode | JSON (brotli) | Binary (brotli) |
|---|---|---|---|---|
| 1,000 | 0.2 ms | 0.0 ms | 76 KB | 77 KB |
| 5,000 | 1.3 ms | 0.1 ms | 357 KB | 363 KB |
| 50,000 | 9.4 ms | 0.8 ms | 3,414 KB | 3,481 KB |
Two findings, and the second one is the honest one:
1. Binary decode is dramatically faster, but only at scale. At 50,000 records, reading two fields out of every record is ~12× faster in binary (9.4 ms → 0.8 ms): JSON has to parse every description string just to reach two numbers; the binary skips them entirely. But at a thousand records, both are under a millisecond. Imperceptible. The win is real and it’s about CPU and memory, not magic, and it shows up only when the client does heavy or repeated work.
2. The size advantage is a mirage. Raw, the binary is ~78% of the JSON. But after brotli (which every CDN applies automatically), JSON’s repetitive text compresses so well that the binary ends up slightly larger. zstd (the newer codec, now a real HTTP encoding) is a near-tie. If you quote raw bytes to justify binary, you’re misleading people. Quote the compressed number.
So the takeaway runs against the reflex: for the wire, JSON is usually fine, and the real lever for size isn’t a fancier codec, it’s a shared dictionary (Compression Dictionary Transport, now in Chrome), which crushes the repetition JSON is full of. Reach for binary when the client-side decode is the bottleneck (big data, weak phones, many reads), not to save bytes. And below a few thousand records, don’t bother: the network round-trip dwarfs all of it, which is exactly why featherweight keeps the network out of the hot path in the first place.
When featherweight wins (and when it doesn’t)
I’m not selling a silver bullet. The honest crossover:
Use it when the local workload is light, the data is yours-and-small or read-mostly, offline and cross-device matter, and you don’t need server-rendered HTML for SEO or first paint. Personal tools, dashboards you own, tabletop sheets, single-user apps, “give my brother his hit points on two devices.”
Don’t when the first paint must be server-rendered for SEO, the payload or the client-side derivation is genuinely heavy (then you’re shipping a decoder and doing the work twice), or you need real multi-writer concurrency (then you want CRDTs, and you’ve left featherweight).
That’s the whole pitch: a large class of apps are over-built. They stand up a backend to do what a static file, a smart client, and a low-latency edge store do faster, cheaper, and offline. (None of this is anti-framework: featherweight is a ~1 KB library, so drop it into React or Svelte too. The thing you don’t need is the backend.)
What’s next
The persistence piece (the local-first client and the low-latency edge store, the part you’d actually reuse) is open source and MIT-licensed: featherweight. It’s about a kilobyte: load() reads local-first, save() debounces to the edge, last-write-wins. Drop the client in, copy one Cloudflare Pages Function, bind a KV namespace, done. The character sheets themselves live over in the hobby corner if you want to poke a real one: drag the hit points, watch them sync to your phone.
The funny thing about chasing the “is it faster” question is where it landed me: not on a faster encoding, but on a lighter architecture. The fastest request is the one you never make. Featherweight just takes that seriously.