Four PWAs that did the same thing, and the layer I finally extracted
I had four hand-rolled Progressive Web Apps doing the same job four different ways, and I was about to paste a fifth. Instead I pulled the shared core out of the two that already worked, made the whole site installable on top of it, and grew the layer through five generations in an afternoon: controlled updates, navigation preload, save-for-offline, and gated web push.
Every few weeks the site grows another little app. A trip log my partner and I shared while she was teaching in Ukraine. A planner for a long weekend in Door County. A pile of browser tools at /utilities. A private inbox where photos of physical mail get read by a vision model and turned into calendar events. Each one wanted the same two things — to install onto a phone's home screen, and to keep working with no signal — so each one got its own service worker. By this week there were four of them, and they were, to a first approximation, the same file pasted four times. I sat down to add a fifth, to make the whole site installable, and caught myself reaching for the clipboard.
Four copies and a smell
The four workers had quietly drifted into two tiers. Two of them, Utilities and the Door County planner, had grown up: when a new version deployed they no longer yanked the page out from under you, they waited, showed a small Update available nudge, and swapped only once you agreed. The other two, the trip log and the inbox, were the older habit, where a fresh worker barges in and takes over mid-session. Same idea, four implementations, two of them better than the other two, and a fifth copy about to inherit whichever file I happened to paste from.
A fifth hand-rolled copy is the exact opposite of how the rest of this site is built. Forty-one utilities share one shell. Every blog post is one renderer walking a list of blocks. The whole place runs on find the thing you keep rewriting and write it once. The service workers were the single corner where I had let four near-identical files pile up, and the fifth was the moment to stop adding to the pile and pull the shared thing out of it.
Extract from the known-good, not the new thing
There is a tempting way to do this and a correct way. The tempting way is to design the shared layer while building the new site-wide app, inventing the abstraction and its first user in the same breath. That is two new things that can break at once, and the abstraction ends up shaped like its only customer.
So I did it backwards. I lifted the shared core out of the two apps that already worked, Utilities and Door County, without changing what they do, and ran them against their own tests until they behaved exactly as before. Two real users from day one force a general abstraction instead of a private one. Only then did the new site-wide app become the third customer of a layer already proven on two apps I open every day, where a regression would be obvious within minutes. The new thing got to be boring, because the load-bearing part was already load-bearing.
A worker that fails closed
The site-wide worker had one genuinely new problem the others did not. It lives at the root, which means it is registered on every page, including the private ones. The inbox holds photos of real mail. The trip log has notes meant for one person. None of that can ever end up in a shared cache.
The naive guard is a denylist: cache everything except these private routes. Denylists fail open — forget one route, or add a new private surface next month, and it silently gets cached. So the root worker uses an allowlist instead. It caches a short, explicit set of public reading surfaces, the homepage and the work and the writing, and nothing else is even considered. Every unlisted request falls straight through to the network, uncached, by default. The safe behavior is the default behavior.
Five generations in an afternoon
Once the layer existed, growing it got cheap, and a little addictive. It went through five generations in a single afternoon, each one opt-in so the older apps kept their exact behavior unless they asked for more.
- v1 to v2, controlled updates. Every app now waits for your nod before applying a new version. No more rug-pulls mid-sentence.
- v3, warm and fast. The worker starts fetching a page in parallel with its own startup instead of after it, and warms a handful of key pages the moment you install, so the first offline session already works rather than only the second.
- v4, save for offline. A Save for offline button on essays, and a durable shelf at /saved that outlives the normal cache churn and even survives deploys. You keep a piece; it stays kept.
- v5, web push. Notifications, wired end to end but gated behind a key — dark until I choose to flip it on, so nothing fires before it is meant to.
// Each app declares the handful of things that make it itself,
// and inherits everything else from the shared core.
self.setupPwaSW({
cachePrefix: 'site',
navAllow: isPublicReadingSurface, // the fail-closed allowlist
precacheUrls: ['/work', '/research', '/blog', '/saved'],
// controlled updates, navigation preload, save-for-offline,
// and the push handlers all arrive from the core.
});What you can actually feel
Strip the architecture away and here is what changed for anyone actually using the site. You can add jakelawrence.xyz to a home screen and it opens like an app, its own icon, no browser chrome. The pages you have read stay readable on a plane. Tap Save for offline on an essay and it is still there in a tunnel three weeks later, waiting on the shelf at /saved. And the apps that update no longer surprise you in the middle of a thought; they ask first.
None of it is flashy. It is the kind of work you only notice by its absence: the page that loads with no bars, the reload that did not happen while you were reading, the cache that never held the one thing it should not.
The fifth copy is never the one worth writing. It is the one that finally admits the other four were impersonating something you had not built yet.
Get the next one
An occasional note when something genuinely new ships here — essays, free tools, projects. No schedule, no filler, easy out.
Need something like this built?
I design and ship AI tools, full-stack apps, and data pipelines — end to end, to production. Tell me the problem in a sentence; I'll give you an honest read on fit within a day.
Work with me →