Cross-device state for static sites. Your data lives in the browser (instant, offline); a low-latency edge KV store syncs it between devices. No backend, no build step, ~1 KB, zero dependencies. Works with any framework — or none; it's just a function.
<script type="module">
import { featherweight } from './featherweight.js'; // copy the file, or use the CDN below
const store = featherweight('my-doc'); // id = the storage key
store.load(state => render(state)); // cache instantly, then remote if newer
saveBtn.onclick = () => store.save({ count: 7 }); // local now, edge debounced
</script>That's the whole idea: local-first reads, last-write-wins sync, through an edge store that has no idea what your data means.
A static site has no server to remember anything. The usual answers are both too big for a lot of cases:
- Rewrite it as an SPA — ship a framework runtime to every visitor to persist a counter. A cargo plane for a sandwich.
- Stand up a backend — a server, a schema, a deploy, to store a blob.
featherweight is the small middle: keep the state on the client, and use the edge as a byte pipe — a key-value namespace that stores and returns one JSON blob per id. On Cloudflare that's a Pages Function + KV binding you can stand up in two minutes, and it deploys with your static site.
Two pieces: the client (browser) and one edge endpoint.
// a) Copy src/featherweight.js into your assets and import it by path:
import { featherweight } from './featherweight.js';
// b) Or from the CDN, nothing to copy:
import { featherweight } from 'https://cdn.jsdelivr.net/gh/VoxellInc/featherweight/src/featherweight.js';
// c) With a bundler (Vite/webpack/React/etc.), bare imports work once it's on
// npm; until then point at the file or the CDN URL above.Copy edge/cloudflare-pages.js to functions/api/featherweight/[id].js, then
bind a KV namespace named FEATHERWEIGHT:
npx wrangler kv namespace create FEATHERWEIGHT
# Pages → Settings → Bindings → add a KV binding named FEATHERWEIGHT
# (Production + Preview), then redeploy.A standalone Worker variant (with CORS, for a separate origin) is in
edge/cloudflare-worker.js.
curl https://yoursite.com/api/featherweight/ping- Before binding KV:
{"data":null,"updatedAt":0,"nobind":true}— thenobindflag means storage isn't attached yet; the client runs local-only and nothing breaks, it just doesn't sync. - After binding + redeploy: the
nobindflag is gone, and aPUTpersists.
npx wrangler pages dev <build-dir> runs the Function plus a local KV, so you
can test real sync before deploying. A plain static file server won't have the
endpoint — the client simply runs local-only against it, which is also fine.
These are the things that actually bite when you drop featherweight into a real, already-built page (learned by doing exactly that — two hand-written character sheets with drag-to-spend hit points, tap-to-burn spell slots, toggled conditions, and an editable notes field):
- Reuse the KV you already have. A live site usually already has a KV
namespace. You don't need a second one — change the single
KV(env)line at the top ofcloudflare-pages.jsto point at it (e.g.env.NOTES). The route and key prefix (fw:) keep featherweight's data from colliding with anything else in that namespace. - It's an ES module. From an existing classic
<script>(a plain IIFE, nottype="module") you can't use a staticimport. Either switch that block to<script type="module">, or load it dynamically:import('/featherweight.js').then(({ featherweight }) => …). - Mind the load gap. Dynamic import is async, so there's a brief window
before the library is ready. If the user can interact in that window, don't
let the initial
load()clobber their input — track an "already touched" flag and, if it's set,save()their state instead of applying the loaded one. (Add<link rel="modulepreload" href="/featherweight.js">to shrink the gap.) apply()must be idempotent.load()calls it with the cached value and then again with the remote value if newer. Set state absolutely (el.classList.toggle(cls, !!on),el.value = data.x) — never append, flip, or increment from insideapply.- Guard fields being edited. If
apply()fires (e.g. a remote sync lands) while the user is typing in an input/contenteditable, writing to it wipes their text and jumps the caret. Skip those:if (el !== document.activeElement) el.textContent = data.notes. - Migrating off bespoke storage? featherweight stores under
fw-<id>, which won't match your old localStorage keys, so existing local state reads as empty on first load. If you need it preserved, do a one-time copy from the old key into asave()before the firstload().
| call | does |
|---|---|
featherweight(id, opts?) |
create a store. opts: endpoint (default /api/featherweight), debounce (ms, default 800), onStatus(s), storage (default localStorage). |
store.load(apply) |
local-first load. apply(data, {source}) fires with the cached value, then the remote value if newer. |
store.save(data) |
write the cache now (survives reload instantly) + debounced PUT to the edge. Safe to call every keystroke. |
store.flush() |
send a pending save immediately (use on pagehide). |
store.peek() |
the current cached value, synchronously. |
data is any JSON-serializable value. The wire/record shape is
{ data, updatedAt }. The client's default endpoint (/api/featherweight)
lines up with the edge route (/api/featherweight/<id>) out of the box.
Use featherweight when all of these are roughly true:
- Single writer. One person editing their own thing across their own devices (a tool, a dashboard, a tracker, a game sheet, notes).
- Small, read-mostly state. Kilobytes, not a database.
- Offline and instant matter. You want it to work on a plane and never spin a loader for local reads.
- No SSR requirement. First paint doesn't need to be server-rendered for SEO.
It shines for personal apps on a CDN where a backend would be overkill — and it drops cleanly into a framework app too, since it's just a function.
- Multiple concurrent writers. It's last-write-wins; two devices editing at once will clobber. If you need real merge, reach for CRDTs (Automerge, Yjs) or a sync engine (ElectricSQL, Replicache/Zero) — a different, heavier, fine tool.
- Anything sensitive or trust-bound. The endpoint has no auth by default — possession of the id is access. Fine for a personal doc; add a capability token (in the id/URL) or real auth before storing anything you'd mind a stranger reading or overwriting.
- Large or heavily-queried data. It stores/returns one blob; it's not a database. KV is eventually consistent (cross-region propagation up to ~60s) and has write-rate limits.
- You already have a backend. Then just use it.
- Read:
load()paints fromlocalStoragesynchronously, thenGETs the blob from the edge and re-applies only ifupdatedAtis newer. The network is never in the way of showing your data. - Write:
save()updates the cache immediately and debounces aPUT. Offline? It stays cached and reportslocal-only; it syncs next time. - Reconcile: last-write-wins by
updatedAt. Simple on purpose. - Edge: a tiny function that puts/gets a JSON string in KV by id. It's deliberately simple — it knows nothing about your data, which is what makes it cheap, cacheable, and replaceable.
featherweight is the persistence piece of a broader idea — static delivery + local-first client + a low-latency edge store — written up here: The Featherweight Pattern (where it fits among SPAs and HTML-over-the-wire, plus the JSON-vs-binary numbers on whether the payload should ever be binary).
MIT © Voxell, Inc.