Konubinix' opinionated web of thoughts

Testing Render and Sync Stacks for Local-First PWAs

Fleeting

Why 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:

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.

Notes linking here