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. (Epilogue, three weeks later: I removed the whole installable layer site-wide and kept only web push. Here's why.)
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. Here's the thing: each one wanted to install onto a phone's home screen and to keep working with no signal, so each one got its own service worker. Four of them by this week. Same file, pasted four times, more or less. This week I sat down to add a fifth and make the whole site installable, and I caught myself reaching for the clipboard.
Four copies and a smell
By now the four workers had drifted into two tiers, and the split was obvious once you looked. Utilities and the Door County planner had grown up: deploy a new version and they wouldn't yank the page out from under you anymore. They waited. They flashed a small Update available nudge and swapped only after you agreed. The trip log and the inbox were still stuck in the old habit, where a fresh worker just barges in and hijacks your session. One idea, four builds, two of them decent and two not. And now a fifth copy, ready to inherit whatever file I happened to paste from.
Think about how backwards that is. Everything else on this site runs on one rule: find the thing you keep rewriting and write it once. Forty-one utilities share a single shell. Every blog post is one renderer walking a list of blocks. The service workers were the lone corner where I'd let four near-identical files stack up, and cutting the fifth was the moment to quit feeding the pile and yank the shared piece out of it instead.
Extract from the known-good, not the new thing
There's a tempting way to do this and a correct way. The tempting way: design the shared layer while building the new site-wide app, inventing the abstraction and its first user in the same breath. Now you've got 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 touching what they do, then ran them against their own tests until they behaved exactly as before. Two real users from day one force a general abstraction, not a private one. Only then did the new site-wide app get to be the third customer of a layer already proven on two apps I open every day, where a regression would jump out within minutes. The new thing got to be boring. The load-bearing part was already load-bearing.
A worker that fails closed
One problem here was genuinely new. The site-wide worker lives at the root, which means it's registered on every page, including the private ones, and private pages are exactly where the danger is. Photos of real mail in the inbox. Notes meant for one person in the trip log. That stuff can never touch a shared cache. Ever.
So how do you guard it? The naive move is a denylist: cache everything except these private routes. Bad idea. Denylists fail open. You forget one route, or someone ships a new private surface next month, and it's silently caching without anyone noticing. The root worker flips this. It uses an allowlist, a short explicit set of public reading surfaces, the homepage and the work and the writing, and it won't even consider anything off that list. Unlisted requests fall straight through to the network, uncached. The safe behavior is the one you get for free.
Five generations in an afternoon
Once the layer existed, growing it got cheap. A little addictive, too. It went through five generations in a single afternoon, each one opt-in so the older apps kept behaving exactly as before unless they asked for more.
- v1 to v2, controlled updates. Every app waits for your nod before it applies 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. It also warms a handful of key pages the moment you install, so the first offline session already works instead of only the second.
- v4, save for offline. A Save for offline button on essays, plus a durable shelf at
/savedthat outlives the normal cache churn and 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 flip it on, so nothing fires before it's 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
Forget the plumbing for a second. What changed for anyone actually using the site? You can pin jakelawrence.xyz to a home screen and it opens like an app, with its own icon and no browser chrome around it. Whatever you've read stays readable on a plane. Tap Save for offline on an essay and it's still sitting there in a tunnel three weeks later, parked on the shelf at /saved. Apps that used to update in the middle of a thought and yank you out of it? They ask permission now.
Nothing here is flashy. It's the sort of thing you only notice by its absence: no loading bars, no reload interrupting you halfway down a paragraph, no cache stubbornly holding the one thing it was supposed to drop.
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.
Epilogue: I tore it all out (June 2026)
About three weeks after I wrote this, I ripped out the whole installable layer. Site-wide. Every service worker, all the manifests, the Save for offline shelf at /saved, the add-to-home-screen prompt - gone. The extraction was still solid engineering. The feature was just the wrong call. On a cold first visit that install prompt would overlay the interactive dock on the game routes and quietly swallow taps, the offline cache could serve a returning reader a stale page, and the payoff never showed up for a personal site nobody actually installs. So I deleted it instead of defending it.
One piece survived because it earns its keep: a minimal, push-only worker (public/push-sw.js), no manifest, no caching, registered on demand so the notification toggles in Mail, Inbox, and Todos keep working. The old worker URLs are self-unregistering tombstones now. Next time a returning browser hits them, they purge their caches and remove themselves, so nobody gets stranded on a cached copy of a site that moved on. The lesson about extracting from the known-good held up fine. The layer it produced did not. Sometimes the most reusable thing you build is the nerve to tear it out cleanly.
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 →