Every developer has lived some version of this. You reproduce a bug, you understand it, you fix it, you ship it. Then someone reports the same bug against the version you just fixed, and for a few disorienting minutes you doubt your own eyes — because the fix is right there in the code, deployed, running. Ours was a two-factor setup screen that got stuck after the user entered their code, and an onboarding tour that would not accept clicks. We had already found and fixed both. The fixes were correct. They were also, for a meaningful slice of returning users, completely invisible.

“Deployed” describes something that happened on your server. “Delivered” describes something that happened in someone’s browser. We had quietly built a machine whose entire job was to keep those two events apart.

Deployed is not delivered

The first thing to establish in a situation like this is which of two very different worlds you are in. Either the fix is not actually on the server — a botched build, a cached Docker layer, a deploy that silently no-op’d — or the fix is on the server and simply is not reaching the browser. These have completely different causes and completely different fixes, and conflating them wastes the most time.

So we asked the server directly, from outside our own network, what it was serving:

shell · what is actually on the server
$ curl -s https://hexvault.co.uk/static/script.js | grep -c '_fullyHideSetupTwoFactorModal'
3   # the fix’s helper is present — the server is serving the new bundle

Three hits. The function that only exists in the fixed version was right there in the file the server handed back. So the deploy was real; the new code was live. And yet the browser in front of us — and the one in front of our tester — was running something older. That narrows it to exactly one place: something between the network and the running page was substituting an old copy. On a modern web app there is really only one thing that does that on purpose.

A caching proxy you install in your users’ browsers

A service worker is one of the most powerful things you can add to a web app and one of the easiest to under-respect. It is a script the browser keeps running beside your pages, and it can intercept every network request they make and answer from a cache instead. That is the whole point — it is how a web app works offline, how it loads instantly on a second visit, how it stops depending on a flaky connection. It is also, described plainly, a reverse proxy you deploy into a place you do not control and cannot easily redeploy. Once it is installed in a browser, that browser talks to it, not to you.

Ours cached static assets with a strategy called stale-while-revalidate, and for most of what it covered that was exactly right. The strategy is simple and, for the right asset, elegant: when a request comes in, serve whatever is in the cache immediately, and in the background fetch a fresh copy to use next time. The user gets an instant response and the cache stays roughly current. For a logo, a font, a stylesheet that changes twice a year, this is free performance with no real downside.

The downside is hiding in the words “next time.” Read the strategy again with a critical asset in mind:

Stale-while-revalidate is a promise that correctness can wait exactly one load. It serves the old copy now and fetches the new one for later. If the old copy is a slightly outdated font, nobody cares. If the old copy is the JavaScript that runs your two-factor flow, then every user runs the buggy version on the visit where it matters, and receives the fix only in time for a visit they may never make.

Our script.js — the file carrying the 2FA setup logic, the onboarding tour, the whole vault transition — was being served under that policy. A returning user opened the app, the service worker handed them the cached script from before the fix, and dutifully downloaded the corrected one into the cache where it sat, unused, waiting for a next load to make it current. The fix arrived. It just always arrived one visit too late.

The second trap: waiting for every tab to close

There was a compounding factor, and it is a subtle one that catches a lot of teams. When you ship a new service worker, the browser does not just swap it in. The new version installs in the background, then waits — by default it will not take control until every tab running the old one has closed. This is a deliberate safety feature: it stops a page from having the rug pulled out from under it mid-session by a worker that behaves differently.

The standard way to opt out of that wait is to call skipWaiting(), which tells the new worker to activate as soon as it is ready. At some earlier point we had removed that call — with a reasonable-sounding justification in the comment, about not wanting to force reloads on users. The effect, combined with the caching strategy above, was quietly severe: not only did a returning user run a stale script, but the new service worker — the one that would eventually correct the cache — also sat waiting for a full close-all-tabs before it could take over. A user who keeps the app open, as people do with a password manager, could drift several releases behind and never once be handed the current code.

Two conservative defaults, each defensible alone, multiplied into a trap. “Serve from cache first” and “don’t activate until tabs close” are both reasonable instincts. Together, on a flow-critical script, they mean a deployed fix can take days to reach an active user — and nothing on your side reports a problem, because from the server’s point of view everything is fine.

The one-line diagnosis

Because the failure is entirely browser-side, the fastest way to confirm it is browser-side too. The fixed bundle contained a function the old one did not. So the question “is this user running the fix?” collapses to a single line in the console:

devtools console · deployed, or delivered?
> typeof _fullyHideSetupTwoFactorModal
'undefined'   // running the OLD cached script — the fix never arrived
'function'    // running the fixed script — look elsewhere

This is worth internalising as a general technique. When you cannot tell whether someone is running the code you think they are, pick something that exists only in the new version — a function name, a constant, a comment marker exposed on window — and probe for it. It turns an argument about “did the deploy work” into a yes-or-no fact. In our case it came back undefined, which ended the mystery: the deploy was fine, the delivery was not.

The fix: match the strategy to the asset

The instinct, once you understand this, is to blow the whole cache away — make everything network-first and be done. That over-corrects, and throws away the reason you added a service worker in the first place. The real lesson is narrower and more useful: a caching strategy is a statement about how stale an asset is allowed to be, and different assets have very different answers. A font can be a day stale. The script that runs your authentication flow cannot be one load stale.

So we split them. Flow-critical JavaScript moved to network-first — try the network, use the fresh copy when online, fall back to cache only when genuinely offline. Everything else — fonts, images, styles — kept stale-while-revalidate, where it belongs.

sw.js · route critical JS to the network first
// App logic carries flow-critical behaviour — a stale copy can
// strand users on an old build. Latest when online; cache only offline.
if (url.pathname.endsWith('.js') && url.pathname.startsWith('/static/')) {
  event.respondWith(networkFirst(request, SHELL_CACHE));
  return;
}
// Fonts, images, css: cache-first is fine, revalidate in the background
event.respondWith(staleWhileRevalidate(request));

And we put skipWaiting() back. That call had been removed to avoid forced reloads, but the fear behind it was misplaced: a new worker activating does not reload anything by itself. Forced reloads only happen if you explicitly listen for the worker changing and call location.reload() — and we do not. So restoring skipWaiting() was safe, and it closed the second half of the trap: a fresh build now takes over promptly instead of waiting for a user to close every tab they own.

The honest caveat: these two changes fix every deploy from now on. They cannot reach back and fix the users already running the old worker, because the old worker is the very thing deciding what they run. Those browsers need one clean load — close all tabs, or clear site data — to pick up the corrected worker once. After that, the drift is gone for good. There is no clever trick around this; a caching layer you shipped can only be replaced on terms it agrees to.

What actually broke

Not the fix. The fix was correct the moment we wrote it. What broke was an assumption — that shipping code to the server is the same as running it in front of a user — and that assumption was wrong specifically because we had added a service worker and then stopped thinking of it as infrastructure. It is infrastructure. It is a proxy with its own caching policy, living in a fleet of browsers you cannot SSH into, and it will keep its promises even when the promise is “serve yesterday’s code.”

The uncomfortable, true version: for a few days, the most important script in our app had a caching policy that treated it like a decorative asset, and a second setting that let old copies of the proxy linger. Both were quiet, reasonable-looking defaults. Neither showed up on any dashboard, because every server-side signal was green — the file was right there, three matches deep. The gap was never on the server. It was in the distance between deployed and delivered, which is exactly the distance a service worker is built to create, and exactly the distance you are responsible for closing on purpose.