Service worker cache invalidation is one of those failures that can make a stable-looking browser suite feel haunted. A test passes against a warm browser profile, then fails after a deploy, a cache reset, a hard reload, or an update to an offline-first app. The code path is not always broken in the obvious way. Often the failure is caused by stale cached assets, a race between activation and navigation, or test code that assumes the network and browser state are reset together when they are not.

If your team is seeing the pattern where browser tests fail after service worker cache invalidation, the fastest path to a fix is not to rerun the suite until it turns green. It is to identify which layer changed, the service worker lifecycle, the cache storage contents, the browser profile, or the test harness itself, and then make that layer observable.

What usually changes when cache invalidation starts failing tests

A service worker changes the normal browser model in three ways that matter for automation:

  1. Requests can be intercepted before they reach the network.
  2. Cached responses can outlive a deploy.
  3. The app may behave differently on first load, update, reload, and offline recovery.

That means a test that once depended on a simple page load can now be coupled to a hidden state machine.

A common failure mode looks like this:

  • First run, the service worker is not installed yet, or the app is in its “fresh install” path.
  • The test passes because it sees the current build.
  • After the app updates, the old service worker stays active until the next navigation or until skipWaiting() and clients.claim() finish.
  • The next run hits a mixed state, part old shell, part new assets, part cached API data.
  • The test fails only after a cache reset, a new build, or a browser context reuse.

That is why a root cause analysis must distinguish between:

  • asset cache invalidation, meaning HTML, JS, CSS, images, or web app shell assets changed
  • data cache invalidation, meaning API responses or IndexedDB-backed offline state changed
  • service worker lifecycle invalidation, meaning registration, activation, and control changed

A flaky browser test around service workers is often not a locator problem first, it is a state management problem first.

Start by identifying the exact transition that breaks the test

Before changing code, answer one narrow question: does the failure happen on cold start, on update, on reload, or after the browser profile is cleared?

Use a small matrix to isolate the transition:

Condition Expected state Failure signal
Fresh profile, no service worker Install path App never finishes bootstrap
Fresh profile, service worker installs Activated, controlling page Old assets still render
Warm profile, app updated New assets and new SW eventually active Mixed UI or stale API data
Cache storage cleared only Network path resumes App assumes cached shell exists
Service worker unregistered only Network path resumes Test waits for offline-first behavior that never happens

This matters because the fix differs depending on what actually changed. If the app expects a registered worker but your test clears storage too aggressively, you can create a failure that never happens in production. If the app expects a clean reload after invalidation, but your test reuses a context and keeps the old worker alive, you can hide a production bug.

Make the service worker visible during test runs

The largest obstacle is that service workers are intentionally behind the scenes. Browser tests need explicit instrumentation so you can tell whether the worker is installed, activated, controlling the page, and serving a cached response.

Log registration and activation in the app

If you control the application code, add temporary or permanent debug logging around registration.

if ('serviceWorker' in navigator) {
  navigator.serviceWorker.register('/sw.js').then((reg) => {
    console.info('sw registered', {
      scope: reg.scope,
      active: !!reg.active,
      waiting: !!reg.waiting,
      installing: !!reg.installing,
    });
  });

navigator.serviceWorker.addEventListener(‘controllerchange’, () => { console.info(‘sw controller changed’, navigator.serviceWorker.controller?.state); }); }

The important part is not the logging syntax. It is the event coverage. You want to know when the worker is waiting, when it activates, and when control actually transfers to the page.

Inspect cache state explicitly

In a debugging session, read caches.keys() and caches.match() rather than assuming the cache is empty or current.

typescript

const keys = await caches.keys();
console.log('cache keys', keys);

for (const key of keys) { const cache = await caches.open(key); const match = await cache.match(‘/index.html’); console.log(key, ‘has index.html’, !!match); }

This is especially useful when the app version is embedded in cache names. A stale cache can remain present even when the app shell has switched to a newer service worker.

Capture browser console and network events

If the test fails after invalidation, the browser console often shows the clue first: a failed fetch, a refused response, a version mismatch, or an unhandled promise rejection in the service worker.

Playwright makes this easy to wire up:

page.on('console', (msg) => console.log('[console]', msg.type(), msg.text()));
page.on('requestfailed', (req) => console.log('[failed]', req.url(), req.failure()?.errorText));
page.on('response', async (res) => {
  if (res.url().includes('/api/')) {
    console.log('[api]', res.status(), res.url());
  }
});

That kind of instrumentation gives you a basic timeline, which is often enough to distinguish cache serving from network serving.

Separate browser profile reuse from service worker behavior

A very common source of misleading failures is test reuse. Many suites reuse browser contexts or profiles for speed. That can be fine for isolated UI tests, but it is risky when service workers are involved.

A reused context can preserve:

  • service worker registrations
  • Cache Storage contents
  • IndexedDB data
  • localStorage flags that alter update behavior
  • session state that changes whether a page triggers a reload

If a suite passes only when it starts from a clean profile, do not immediately conclude that the app is broken. First check whether the suite is depending on profile reuse in a way the app never does in production.

Practical rule

Use a clean browser context for tests that verify update, first-run, or offline behavior. Reuse context only when the test is explicitly about persistence.

That tradeoff is slower, but it is usually cheaper than triaging cache-related flakiness across multiple CI jobs.

Understand the service worker lifecycle that can bite your test

The lifecycle matters because invalidation is not instantaneous.

A simplified view:

  1. install runs, the worker downloads assets and prepares caches.
  2. waiting means the new worker exists but has not taken control yet.
  3. activate runs, cleanup happens, old caches may be deleted.
  4. clients.claim() can let the new worker take control of open pages.
  5. controllerchange signals that the page is now controlled by the new worker.

A browser test that navigates immediately after triggering an update may see the old worker for one request and the new worker on the next. That mixed state is a classic source of browser cache invalidation test failure.

If your test depends on a service worker update, wait for the update to become observable in the page, not merely for the registration promise to resolve.

Wait for control transfer

With Playwright, you can wait for a controller change or a known app signal.

typescript

await page.goto(appUrl);
await page.waitForFunction(() => navigator.serviceWorker.controller !== null);

That alone is not always enough, because control can exist before caches are fully populated. A stronger pattern is to wait for an app-level readiness signal, such as a version endpoint, a bootstrap flag, or a DOM marker that is written after the app has fully loaded under the new worker.

Diagnose whether the failure is cache, update, or data drift

When teams say “the cache is invalidated,” they often mean different things.

1. Static asset drift

The app shell loads old JavaScript, but the HTML or API response points to a new route structure. Symptoms:

  • chunk load errors
  • missing exported functions
  • blank page after deploy
  • old bundle calling new API routes incorrectly

This usually means the service worker served a stale asset that is now incompatible with the page or backend.

2. API response drift

The UI is present, but data looks old or wrong after invalidation. Symptoms:

  • stale user profile or feature flag state
  • incorrect cache keying for authenticated requests
  • responses reused across users or tests

This is often a caching bug in the worker fetch strategy, not in the test. Check whether the request includes headers or tokens that the cache key ignores.

3. Offline-first bootstrap drift

The app behaves differently if the network is slow, unavailable, or restored after invalidation. Symptoms:

  • spinner never resolves
  • test waits for data that was supposed to be hydrated from cache
  • a reconnected page loads but never refreshes its stale state

This tends to show up in PWA test flakiness because the app is built to tolerate network loss, while the test assumes immediate consistency.

Use versioned signals, not just DOM selectors

If a test checks only that a button exists, it may miss a stale shell that is still technically functional. The better signal is a combination of UI and versioned runtime information.

Good candidates include:

  • a build hash rendered into the page
  • a meta tag with the current app version
  • a response header from a /version endpoint
  • a client-side variable exposed only in test or debug builds

Example pattern:

typescript

await page.goto(appUrl);
const buildId = await page.locator('[data-build-id]').textContent();
expect(buildId).toContain(process.env.EXPECTED_BUILD!);

This is useful because it tells you whether the page belongs to the new deployment or a stale cached shell. It is a cleaner signal than waiting for a generic selector that might exist in both versions.

Add assertions around the service worker itself

A lot of teams only assert UI behavior. For service worker issues, assert lifecycle behavior too.

Useful checks include:

  • the registration exists
  • the worker is active
  • the controller is present
  • cache keys match the expected version
  • the app can recover after cache deletion

In Playwright, you can evaluate browser-side state directly:

typescript

const swState = await page.evaluate(async () => {
  const reg = await navigator.serviceWorker.getRegistration();
  return {
    controlled: !!navigator.serviceWorker.controller,
    scope: reg?.scope,
    active: reg?.active?.state,
    waiting: reg?.waiting?.state,
    caches: await caches.keys(),
  };
});

console.log(swState);

If a test fails only after invalidation, the state returned here is often more useful than the UI screenshot.

Reproduce the failure the same way the browser does

Browser automation can accidentally hide the bug if it resets too much state between steps. To reproduce a service worker problem, mimic the sequence that production users actually experience.

A useful reproduction sequence is:

  1. load the app on version A
  2. let the worker install and control the page
  3. deploy version B or swap the app shell in a staging environment
  4. reload or navigate
  5. observe whether the app uses old caches, new assets, or a mixed state

For CI, you can approximate that with a forced cache reset and second navigation:

typescript

await page.goto(appUrl);
await page.context().clearCookies();
await page.evaluate(async () => {
  const keys = await caches.keys();
  await Promise.all(keys.map((k) => caches.delete(k)));
});
await page.reload();

That is not a full production simulation, because service worker lifecycle timing still depends on the browser and hosting setup. But it does help separate a stale-cache issue from a locator issue.

When Selenium Grid makes this harder

On Selenium Grid, the browser may be remote, disposable, and harder to inspect interactively. That does not change the underlying problem, but it does change the debugging workflow.

A practical pattern is to collect more artifacts per run:

  • browser console logs
  • network logs or HAR files when available
  • screenshot on failure
  • browser version and platform
  • app build version and service worker version

If your grid provider supports persistent video or logs, turn them on for the specific flaky suite. Service worker problems often reveal themselves as a timing mismatch, and video plus logs can show whether the page reloaded, whether a network request was intercepted, or whether the app stayed on the old shell.

Common root causes and the fix that usually follows

Stale cache version names

If cache names do not change when the app changes, old assets can remain in use longer than intended.

Fix: include a build identifier in cache names and delete old versions during activation.

Over-aggressive cache deletion in tests

If the test wipes Cache Storage or unregisters the worker at the wrong moment, it can break the app bootstrap path.

Fix: only clear state in a controlled setup step, then wait for the app to fully re-register and control the page.

Race between update and navigation

If navigation happens before the new worker controls the page, requests can be served by different versions.

Fix: wait for controllerchange, or for an app-specific readiness marker, before asserting.

Non-idempotent bootstrap logic

If the app assumes registration or initialization runs once only, reloading after invalidation can break it.

Fix: make bootstrap safe to run multiple times, and test that behavior explicitly.

Cache keying bug for authenticated or locale-sensitive content

If the service worker caches API responses without considering auth or locale, invalidation can expose the bug because a fresh page load pulls the wrong variant.

Fix: include the relevant request dimensions in the cache key, or bypass cache for those endpoints.

A debugging checklist that pays off quickly

Use this sequence when a test only fails after cache invalidation:

  1. Confirm whether the page is controlled by a service worker.
  2. Record the active, waiting, and installing states.
  3. Capture cache names before and after the failure.
  4. Compare the current build version to the expected build version.
  5. Check whether the failure is on first load, reload, or post-update navigation.
  6. Verify that the network request path really changed, not just the UI.
  7. Re-run in a clean browser context to separate profile reuse from app behavior.
  8. Keep the failing state artifact, do not rely on a human eyeballing the page later.

This checklist sounds basic, but it prevents a lot of unproductive thrash.

A stable test design for offline-first apps

If your app is a PWA or otherwise offline-capable, the tests should reflect that design instead of fighting it.

A good structure is:

  • one test for first install and control
  • one test for update and activation
  • one test for offline behavior after a warm cache
  • one test for recovery after cache reset

Each test should assert the specific contract it is exercising. Do not make one test prove all four behaviors. That creates brittle setup and hides which invariant broke.

In practice, this aligns with the general purpose of software testing and test automation, which is to verify specific behavior under controlled conditions, not to accidentally simulate the entire product at once.

CI tips that reduce flakiness without hiding defects

In continuous integration, service worker issues get worse when a suite runs in parallel against shared environments or shared profiles. A few operational rules help:

  • use isolated browser contexts per test or per worker
  • pin browser versions in CI where possible
  • make app build versions visible in logs
  • separate update tests from general UI smoke tests
  • fail fast on console errors from the service worker

The broader lesson is that continuous integration only helps if the test environment is deterministic enough to surface real state transitions, not random leftovers from the prior job.

When the bug is in the app, not the test

Sometimes the test is simply the first thing to expose a real defect. That is especially true when a service worker update path does not coordinate with the app shell.

Red flags in the application include:

  • the worker activates, but the app never refreshes old state
  • a new deploy introduces a route or chunk name the old worker still references
  • cache deletion breaks the app because bootstrap depends on old data being present
  • the app cannot detect that its worker has changed

If the same failure appears in manual reproduction with a clean browser profile and the same update sequence, treat it as a product bug first. The test is not flaky, it is exposing an unsupported state transition.

The short version

When browser tests fail after service worker cache invalidation, the real problem is usually one of three things: hidden browser state, an update race, or an app that does not tolerate mixed-version loads. The fix is to make the service worker lifecycle observable, assert versioned state instead of only DOM presence, and keep update tests separate from ordinary UI checks.

If you do that, the next failure will tell you something useful instead of just consuming another morning.