TLDR: If your service worker bootstrap hangs after the first registration, stop waiting for controllerchange. Just await serviceWorker.ready and reload().

The Setup

I built a presentation site for a marketing client (a workshop I ran in May) — slides, embedded videos, the whole deck living in the browser.

Conference wifi is unreliable by nature, and hanging on a video mid-presentation is a nightmare I wasn't willing to risk.

So I added a service worker (a browser feature that intercepts network requests and serves assets from cache, enabling offline-first behavior) to pre-cache all the deck media before the workshop started.

While I was at it, I locked the whole thing behind AES-256-GCM client-side encryption — one password, the real content decrypts in-browser, no server calls.

That part felt CLEAN.

The Wall

Testing the unlock flow the night before the workshop, I clicked Unlock workshop

…and the spinner just ran.

Forever.

The maddening part: it didn't hang on every device. Some browsers flew right through. Others froze completely. That inconsistency is the tell — a timing issue, not a logic bug.

What I Tried (And Why It Was Wrong)

The original unlock code did something reasonable-sounding: register the service worker, then wait for it to "claim" the current page before reloading.

await navigator.serviceWorker.addEventListener(
  'controllerchange', () => resolve(), { once: true }
);

The problem? When a page registers a SW for the first time, it's uncontrolled — no SW is managing it yet. The controllerchange event only fires if a controller changes. On first registration, the listener just sits there waiting for an event that may never arrive. Race condition baked in, device behavior decides whether you hang or not.

The Fix (Two Lines + Two Hardening Notes)

Stop trying to grab control of the current page. Reload into the SW's scope instead. The next navigation is intercepted by the active SW — that's just how scope works.

async function bootstrapAfterUnlock() {
  await navigator.serviceWorker.register('/sw.js', {
    scope: '/',
    updateViaCache: 'none',
  });
  await Promise.race([
    navigator.serviceWorker.ready,
    new Promise(r => setTimeout(r, 5000)),
  ]);
  window.location.reload();
}

Two things that matter beyond the core pattern:

  • updateViaCache: 'none' — without it, the browser's HTTP cache can serve a stale sw.js after a redeploy. The new SW never installs. Set this on every register() call where the SW source can change (which is always).
  • sessionStorage one-shot guard — if your app auto-bootstraps from saved state (say, a stored unlock key), wrap it in a session flag. Otherwise a busted reload can loop: unlocker → reload → unlocker → reload… indefinitely.

Shipped the patch as 78c2bfe. Workshop went fine.

Why This One Stuck With Me

The SW lifecycle is racy by design — the spec says so explicitly. Any code that assumes a specific moment of activation is fragile, and the fragility shows up inconsistently by device, which makes it feel like a hardware bug when it's really a timing assumption.

The lesson I carry: don't fight the async lifecycle. Give the platform the reload it wants, and let the SW intercept it cleanly on the next navigation. That's the pattern — and it works every time.

P.S. The controllerchange pattern isn't wrong — it's useful for SW updates on pages that are already controlled. It's just the wrong tool for first registration. Context is everything with SW lifecycle code.