Testing Render and Sync Stacks for Local-First PWAs
FleetingWhy this note
Each small web app I build is a probe into the same design space: how to write a local-first PWA that is a pleasure to write and to change. Rather than settle on one framework, I deliberately pick a new rendering stack for each app — sometimes a new sync / persistence backend too — so that over time I build first-hand judgement across the whole space instead of a habit around one tool. As I put it while starting the fourth of these: “Three apps in… time for another stack.” This note is the map of that experiment: the axes, the rule, and every app placed on the grid.
The apps are all real (each one earns its keep in daily use) — the stack rotation rides on top of genuine needs, it doesn’t invent them.
The rule and the constraints
One rule: never reuse a rendering stack. A new app takes one not yet tried, so the grid keeps widening. Every app is then held to the same constraints, which are what “a pleasure to write and change” cashes out to:
- Locality of Behaviour — the code that governs a piece of UI lives next to that UI, not scattered across layers. This is the strongest filter on stack choice.
- No build, no bundler. Everything loads as ES modules through an import map from esm.sh. The edit→reload loop has no compile step in the middle.
- No big-bundle fuss — but a small bundle is fine. The point isn’t zero dependencies; it’s not paying a bundler/toolchain tax. A few KB pulled straight from a CDN is welcome.
- Fast iteration in literate programming — each app is a single literate note: prose-first, test-first (Playwright), blocks short, rewrite over patch.
- Offline-capable PWA — a manifest + service worker, installable to the phone.
Two axes fall out of this: how the UI renders and how state persists and syncs. An app is a point on that grid.
The grid
Rendering stacks tried so far: Preact+htm, Solid, Vue 3, petite-vue, Alpine, Lit, uhtml, hyperapp+hyperlit, Van.js.
Sync / persistence tried so far: GraphQL over Postgres (urql/PostGraphile), Yjs, Automerge, Loro, TinyBase, plain localStorage, IndexedDB / idb-keyval.
| App | Render | Sync / persistence |
|---|---|---|
| frise chrono | Preact + htm | GraphQL over Postgres (shared data layer, urql) |
| photos/videos organiser (memories) | Solid | GraphQL over Postgres (shared data layer) |
| duplicate-photo batch resolver | hyperapp + hyperlit | GraphQL over Postgres (shared data layer) |
| slides in vue3 | Vue 3 | GraphQL over Postgres |
| scrutin de Condorcet | petite-vue | Automerge (CRDT) + IndexedDB |
| score counter tally | Alpine | Yjs (y-websocket + y-indexeddb) |
| some simple podcast player | Alpine | Yjs (y-websocket) + localStorage |
| simple pwa organiser | Lit | TinyBase (+ IndexedDB) |
| trigger list | uhtml | Loro-CRDT + idb-keyval |
| simple pwa piano hero | Van.js | TinyBase |
| simple pwa karate beep trainer | Van.js | localStorage |
| detect duplicate photos (early) | Alpine | localStorage ($persist) |
| ipfsdocs slider | Alpine | reads the archive + localStorage |
| vignettes à imprimer | à décider | none — local files only |
A caveat on names: a photos/videos organiser with preact is a legacy title — it actually renders with Solid. And Van.js appears twice, which looks like a broken rule, but the pairing differs (TinyBase vs plain localStorage): the second axis is what was being probed there.
Shared infrastructure
Some pieces are stack-independent, so they live once and every app noweb-includes them — the service-worker handlers, PWA meta, the build-hash, the Playwright harness helpers:
- shared blocks — SW, PWA meta, build-hash, test scaffolding, reusable engines.
- PWA score apps — shared blocks — the Yjs import map, SW and manifest the score-style apps share.
- the data layer behind my photo apps — the GraphQL/Postgres backend the frise, memories, the resolver and the slider all read.
- how to do literate programming — the method every app note follows.
What’s left to try
Rendering stacks not yet touched that still fit no-build + LoB: Mithril, Sinuous, Reef, Knockout. On the sync axis: a WASM SQL engine (PGlite / SQLite WASM), OPFS-backed storage, or a hosted sync (ElectricSQL) would each open a new row.