Sep 2026 · 8 min · Delivery

The Silent Reload

How a stale-cache bug in an Angular build turned into a service worker that knows the difference between "update when convenient" and "update right now."

Field notes from a release pipeline, fall 2024.

The bug

Every build deletes the previous one

A production Angular build fingerprints every bundle with a content hash - main.a1b2c3d4.js, styles.9f8e7d6c.css. Ship a new build and the old hashes are gone from the server, replaced by a fresh set. Normally that is invisible: a browser requests index.html, that file points at whatever hashes are current, and the two always agree.

They stop agreeing the moment a tab has been open across a deploy. Someone who logged in that morning and never refreshed is still holding an index.html from before the release. The instant it tries to lazy-load a route chunk, it is asking the server for a file that no longer exists. Best case, a chunk-load error. Worse case, that tab keeps running against an API contract the new build already moved past.

Caching less aggressively is not the fix - the hashing exists so browsers can cache aggressively. What is missing is a way to tell every open tab, unprompted, that a new build exists.

01 - Reload on ready

Angular already knows the answer

The Angular service worker registers itself once the app is stable and polls the server for a version manifest in the background. When the build a tab is running diverges from the build the server is now serving, it fires exactly one event: VERSION_READY.

The obvious first move is to subscribe to that event and reload the window the moment it fires. It worked - every open tab was current within a couple of minutes of a deploy. It also fired on a dispatcher three fields into building a rate offer, and took the unsaved offer with it. To a service worker, shipping a routine update and interrupting someone in the middle of their afternoon look identical. Both are just apply the update.

02 - Wait for a checkpoint

Reload at a moment nothing is lost

The fix is not to reload less - it is to reload at a point where nobody is mid-thought. The Angular router already marks that point for free: NavigationEnd. Moving between routes means someone just closed a dialog, finished a load, or followed a link. They are between two pieces of work, not inside one. That is the checkpoint.

So the handler stopped reloading on VERSION_READY directly. Instead it arms a one-shot listener for the next navigation, and only reloads when that fires:

ts
this.swUpdate.versionUpdates
    .pipe(filter((evt): evt is VersionReadyEvent => evt.type === 'VERSION_READY'))
    .subscribe((evt) => {
        const navEnd = this.router.events
            .pipe(filter((e) => e instanceof NavigationEnd), take(1))
            .subscribe(() => this.windowReload());
        // ...appData comparison for the forced path lives here too
    });

If someone stays on one screen for an hour, the update just waits. The worker keeps polling every two minutes underneath them, but the reload stays parked until they navigate on their own. The only thing a user notices is a page load half a second longer the next time they click somewhere, and then they are current without having thought about it.

03 - The override

Some releases cannot wait for a checkpoint

A checkpoint that waits for the user is the right default. It is not always a safe one. Some releases change the shape of what client and server agree on closely enough that another hour on the old build is not an inconvenience - it is a bug waiting for the right sequence of clicks. Those releases need a way to say not at the next stop, now.

That decision does not belong in application code at all. It belongs to whoever is cutting the release, at the moment they are cutting it.

So it lives in the pipeline, as a boolean parameter on every environment build definition, off by default:

pipelines/Production/production-CICD.yml
parameters:
  # Set to True to force every client onto this build immediately
  - name: requiresUpdate
    type: boolean
    default: false
    displayName: 'Force Update'
  # Clears stale grid-filter state that no longer matches the new grid
  - name: deleteGridCache
    type: boolean
    default: false
    displayName: 'Force Delete Grid Cache'

A step that runs just before the Angular build stamps those flags - along with the build number - into the service worker manifest, so they are compiled into ngsw-config.json rather than read from anywhere at runtime:

ngsw-config.json
{
    "appData": {
        "version": "2026.9.3-14",
        "requiresUpdate": false,
        "deleteGridCache": false
    }
}

Every VERSION_READY event already carries both manifests, current and latest, because the checkpoint logic needed the version numbers to compare. Reading requiresUpdate off the same payload is not a new code path, just one more branch on it. If the versions differ and the latest manifest demands an update, skip the wait for NavigationEnd entirely, put a modal in front of the user with exactly one button, and reload the moment they close it. The modal says the app is out of sync and requires an update. One button: Reload. No dismiss, no snooze.

How it branches

One trigger, two very different responses

  • Standard release: requiresUpdate is false, so the handler arms the listener, waits for NavigationEnd, and reloads silently on the next navigation.
  • Forced release: requiresUpdate is true, so the handler skips the checkpoint, shows the blocking out-of-sync modal, and reloads immediately on dismiss.

The quieter sibling flag, deleteGridCache, rides the same comparison. Some releases do not just add a field to a grid - they change what a saved filter means. Rather than leave a stale filter object shaped for a grid that no longer exists, that flag clears the specific localStorage keys for the affected grids on the same version boundary, standard path or forced.

  • version: the build number, stamped in by the pipeline - what the two manifests actually diff against.
  • requiresUpdate: skips the navigation checkpoint and forces an immediate, blocking reload.
  • deleteGridCache: clears saved grid-filter state that no longer matches the new schema.
What this buys

The decision moved, not just the code

The interesting part, in hindsight, was never the service worker - that is a few dozen lines wired to two RxJS streams. It is where the decision ended up living. Whether this particular release should interrupt people mid-work is not a question the client can answer well on its own. It depends entirely on what actually shipped, and only the person cutting the release knows that at the moment it matters.

Putting Force Update behind a pipeline checkbox means that judgment gets made once, by whoever has the context to make it, with their finger already on the deploy button - instead of getting hard-coded into behavior that treats a font tweak and a breaking contract change exactly the same way.

The default stays polite: wait for a natural gap, reload quietly, cost half a second on the next click. The override exists for the handful of releases where polite is the wrong instinct. And it is a checkbox, not a redeploy.

Disagree? Tell me →