The Reload Loop That Reproduced on Pages It Never Touched
Clearspar Heli is a concept prototype of a day-of-operations system for a heli-ski/heli-charter operation — not a real operator, not real flights, not real guests. Every aircraft and person in the demo is invented sample data, and it is not a certified operational safety system. This post isn't a security bug or a data bug, though; it's a debugging story about the build itself. The bug never touched production and never touched a user — it was a local-preview annoyance. What makes it worth writing up is that the symptom pointed nowhere near the cause, and untangling that was the whole job.
The symptom: under `next dev`, the local preview fell into an endless full-page reload — roughly one every 150 milliseconds, fast enough that the page never settled. Three things made it strange. It reproduced on the landing route, "/", which never registers a service worker and never runs the code you'd suspect. It survived restarting the dev server. And it survived a manual, one-off service-worker unregister in the browser devtools. A machine that had loaded the app once stayed broken, and the usual reflexes for un-breaking it did nothing.
The relevant background is that the platform is offline-first. A service worker (sw.js) caches the app shell so the field and guide views load without a signal — in production, that's a real feature, not a nicety, because the whole point of the guide view is that it works on a mountain with no bars. The confusing part was that a feature scoped to the field views was somehow taking down a marketing page that has nothing to do with it.
The cause is a chain, and every link matters. First: sw.js calls skipWaiting() and clients.claim(). The effect is that the moment it registers — which happens from /platform — it takes control of the whole origin, scope "/", including the landing route that never calls the registration hook. That's why an untouched page was affected. Second: the service worker's staleWhileRevalidate handler answers navigations with a cached app-shell HTML document. Third: the cache name is stamped to a real build id only by a postbuild script (scripts/stamp-sw-cache.js), which runs after `next build`. Under `next dev` that step never runs, so the cache name is the literal, unstamped "heli-ops-cache-__BUILD_ID__" — pinning a shell captured from an earlier Turbopack compile. Fourth: Turbopack's dev runtime loads, sees the served document doesn't match the live compilation, and forces a location.reload() to recover. Fifth: that reload is a navigation, so the service worker answers it from the same stale cache again — mismatch, reload, mismatch, reload, forever.
That chain also explains why the obvious fixes failed, which was the part that made it feel like a heisenbug rather than a bug. skipWaiting() plus clients.claim() means a fresh service worker re-takes control on the very next load, so a one-off manual unregister never sticks — the loop resurrects itself immediately. And because the worker controls scope "/", neither restarting the dev server nor opening a "clean" route escapes it; there is no clean route as long as one worker owns the whole origin. Each individual decision in the chain — claim the origin, serve the shell from cache, stamp the cache name at build time — was reasonable on its own. The loop only existed in the gap between them, in the one environment (`next dev`) where the stamping step doesn't run.
The fix guards on the environment. Under `process.env.NODE_ENV !== "production"` the registration hook now does two things: it never registers, and it proactively self-heals — it enumerates navigator.serviceWorker.getRegistrations() and unregisters all of them, and deletes every cache key that starts with "heli-ops-cache-". So a machine a prior session had broken clears itself on the next dev mount, instead of needing someone to know the exact devtools incantation. The cleanup is all best-effort and its errors are swallowed, because dev-only housekeeping must never surface an error or block the app from loading. Production is deliberately untouched: `next build` inlines NODE_ENV="production" into the static export (this app is `output: "export"`, served same-origin by FastAPI — there is no `next start`), so the shipped bundle takes the normal register() branch exactly as before, and the offline behavior the service worker exists to provide is unchanged.
The lesson here isn't about service workers specifically. It's that the distance between a symptom and its cause is not a measure of how hard a bug is — it's a warning about which fixes are traps. The tempting move was to treat "reload loop in dev" as noise and reach for the blunt instrument: rip the service worker out of dev and stop thinking about it. That would have worked, and it would have quietly widened the gap between what dev runs and what prod runs — the exact gap that produced the bug in the first place. Tracing the actual chain instead, skipWaiting to whole-origin scope to unstamped-in-dev cache to Turbopack mismatch to reload, was slower, but it's what let the fix be surgical: heal the dev environment, prove the prod path was never on the branch that changed, and leave the offline feature it was protecting completely alone.