Browser tests that pass all day, then start failing right after a service worker update ships, are a special kind of frustrating. The app looks fine in a manual session, the CI run was green before deploy, and then suddenly login, navigation, or asset loading breaks in a way that seems tied to one release window. These failures are often not random at all. They come from a predictable set of problems around service worker registration, activation timing, cached assets, route interception, and offline fallbacks.

If you have ever seen a test suite behave differently immediately after a PWA update, you are dealing with a boundary between browser automation and app lifecycle management. That boundary matters because a service worker can keep serving old assets while the page expects new ones, or vice versa. It can also change how network requests are fulfilled, which means the test is not always exercising the network path you think it is.

The hard part is not simply that a service worker exists. The hard part is that browser tests can observe different application states depending on whether the old worker is still controlling the tab, whether the new one has activated, and whether cached responses were populated from a prior run.

Why these failures cluster around service worker updates

A service worker update is not just another deployment artifact. It changes client-side behavior across multiple browser sessions, sometimes asynchronously, and sometimes only after the next navigation or reload. That makes it easy for test runs to straddle two application versions at once.

In a simplified sequence, this is what can happen:

  1. The app loads version A and registers service worker A.
  2. A new build deploys version B with updated assets and a new service worker script.
  3. The browser downloads the new worker, but the old worker still controls the current page.
  4. Some requests are served from cache A, some from the network, and some from the new worker after activation.
  5. The test now sees a mixed state, which can break assertions in ways that only appear during release windows.

This is why the issue often appears as flaky tests after PWA update rather than a clean, repeatable failure. The app is moving between states that normal browser automation does not explicitly model.

For background, software testing and test automation are both about making behavior visible and repeatable, but service workers intentionally add statefulness to the browser runtime. The browser itself, not only your app code, becomes part of the state machine. See software testing and test automation for the broad concepts, and MDN’s service worker API for the browser-side mechanics.

The failure modes to check first

When a browser tests fail after service worker update issue appears, start by identifying which of these categories you are in.

1. Cached JS or CSS is out of sync with HTML

A classic symptom is a page shell that references asset hashes from the new deployment while the service worker still serves old bundles. The browser loads an HTML page from the network, but the JS bundle comes from cache, or the reverse. That can create a runtime error such as missing exports, undefined component trees, or hydration mismatches.

Common signs:

  • JavaScript exceptions immediately after reload
  • Uncaught module import errors
  • CSS missing after update, leading to layout-related assertions failing
  • App shell rendering but interactive controls never attaching

2. The service worker has not activated yet

A newly installed worker does not necessarily control the page right away. Depending on your registration flow, the browser may require a reload before controllerchange occurs. If the test assumes the update is live immediately after deploy, it may interact with the old code path for some requests and the new one for others.

Common signs:

  • First test run after deployment fails, retry passes
  • Reload fixes the issue manually
  • Failures only happen on cold browsers, not warmed local profiles

3. Offline fallback intercepts a route unexpectedly

If your service worker is configured to return an offline page or cached app shell for navigation requests, a request that should hit the network might instead get fallback content. This is especially painful in browser automation service worker setups where the test expected a status code or specific DOM from the live backend.

Common signs:

  • URL changes, but the page content is the offline fallback
  • Assertions find the wrong title or markup after navigation
  • API-like endpoints return synthetic content from the service worker

4. Cache cleanup is incomplete

Many update strategies rely on deleting old caches in the activate event. If cleanup is partial, or the worker crashes before finishing it, old and new caches can coexist. That makes failures non-deterministic across machines, CI runners, and browser brands.

Common signs:

  • Only one browser family fails, often due to timing differences
  • Failures correlate with low disk space or slow devices
  • A local browser profile accumulates stale cache keys over repeated runs

5. The test harness itself is unaware of the worker lifecycle

Frameworks like Playwright and Selenium do not automatically wait for your service worker to settle. They wait for DOM conditions, network idle, or explicit selectors, but they do not understand the app’s internal update state unless you teach them.

Common signs:

  • A test passes with hard reloads but fails with normal navigation
  • The same steps fail in CI and pass on a developer laptop, or the opposite
  • Re-running the same spec against a fresh context removes the flake

Make the service worker visible before you try to fix it

The first debugging mistake is to treat the failure as if it were only a browser automation problem. In reality, you need visibility into worker registration, cache contents, and version boundaries.

Add instrumentation in the app, or expose enough state for tests to inspect it. For example, surface the active service worker version in a meta tag, a global variable, or a debug endpoint. Then tests can tell whether they are observing version A or version B.

A simple browser-side diagnostic can help:

typescript

await page.waitForFunction(() => {
  return Boolean(navigator.serviceWorker?.controller);
});

const swState = await page.evaluate(async () => { const regs = await navigator.serviceWorker.getRegistrations(); return regs.map(r => ({ scope: r.scope, active: r.active?.state, installing: r.installing?.state, waiting: r.waiting?.state })); });

console.log(swState);

This tells you whether a worker is active, waiting, or still installing. If you see a waiting worker repeatedly, you may be hitting an update flow that never claims control during test execution.

You can also inspect cache names and stored requests:

typescript

const cachesInfo = await page.evaluate(async () => {
  const names = await caches.keys();
  return Promise.all(names.map(async name => {
    const cache = await caches.open(name);
    const keys = await cache.keys();
    return { name, entries: keys.length };
  }));
});

If one environment has extra caches from older deployments, you may have found the real source of the flake.

Reproduce the problem in a controlled state

A good debugging session begins by controlling browser profile state. Do not start by replaying the same failing CI job endlessly. Instead, create a minimal reproduction with three states:

  • brand-new browser context, no service worker and no cache
  • browser context after the first successful registration
  • browser context after a deploy that introduces a new service worker version

That lets you answer a key question: does the failure require an existing cache, or does it happen on the first load after deploy too?

In Playwright, it is often useful to start with a new context and a fresh persistent profile only when you need to reproduce a real-user upgrade path. Otherwise, use a clean context so you can isolate whether the issue is due to stale storage.

typescript

const context = await browser.newContext({ serviceWorkers: 'allow' });
const page = await context.newPage();
await page.goto('https://app.example.test');

For an upgrade-path reproduction, the opposite is often useful, a persistent context where prior storage survives between launches. That mirrors what a real user experiences when a service worker update ships.

Differentiate network failures from worker-served failures

Before changing waits or selectors, determine whether the request went to the network or was intercepted by the service worker. This matters because the fix may be in cache invalidation, not in test timing.

Practical checks:

  • Inspect browser DevTools application panel during a manual run
  • Log fetch events inside the service worker for test environments
  • Add server-side request logging to confirm whether the backend ever received the request
  • Compare the response body to the expected network payload

If the server never saw the request, the worker likely handled it. If the server did see it but the UI still used old data, your app may be caching API responses in a way the test did not expect.

A small fetch probe can help in Playwright:

page.on('response', async response => {
  if (response.url().includes('/api/')) {
    console.log(response.status(), response.url());
  }
});

For deeper debugging, instrument the service worker itself in non-production builds to report events back to the page or to a test endpoint. That is often more useful than trying to infer everything from page-level network events.

Watch for update timing bugs

Service worker updates are often triggered during navigation, but the new worker may not control the current tab until later. This creates timing problems that look like ordinary flakiness if you are not paying attention.

The most common timing bug is asserting against UI state immediately after deployment or reload, when the app has not finished switching over. A naive wait for networkidle does not solve this if the worker serves cached resources without network activity.

Instead, wait for an explicit signal that the new worker is active. If your app exposes one, prefer that. If not, observe controllerchange.

typescript

await page.evaluate(() => {
  return new Promise<void>(resolve => {
    if (navigator.serviceWorker.controller) {
      resolve();
      return;
    }
    navigator.serviceWorker.addEventListener('controllerchange', () => resolve(), { once: true });
  });
});

This pattern is not a silver bullet, but it is better than hoping the new worker has taken over by the time your next assertion runs.

Reset state the right way, not the blunt way

When service worker caching issues in browser tests show up, it is tempting to clear everything before every spec. That can hide bugs, slow the suite, and make the tests less realistic. Sometimes you want a clean profile, but other times you need to reproduce a user upgrading an already installed app.

Use state reset intentionally:

  • For isolation tests, clear cookies, localStorage, IndexedDB, and caches between specs
  • For upgrade-path tests, keep the storage and simulate a real deploy
  • For debugging, compare clean runs to warm-profile runs

If you are using Selenium, remember that browser profile management is usually less ergonomic than in Playwright. You may need to launch separate user data directories or rebuild the session more aggressively to simulate a fresh install state.

A simple Python Selenium example to reduce profile carryover might look like this:

from selenium import webdriver
from selenium.webdriver.chrome.options import Options

options = Options() options.add_argument(“–user-data-dir=/tmp/test-profile-clean”) driver = webdriver.Chrome(options=options)

Use this sparingly, because a brand-new profile is not equivalent to an update path. It is only one part of the diagnosis.

Make cache versioning boring

Many flaky tests after PWA update issues are symptoms of overly clever cache naming. If your build pipeline reuses cache names too long, or if the worker caches assets without a strong version boundary, the browser can mix old and new artifacts.

Good debugging questions:

  • Are cache names tied to a build identifier or content hash?
  • Does the activate handler delete every cache from prior releases?
  • Are HTML documents cached at all, and if so, should they be?
  • Are API responses cached with a separate policy from static assets?

A common pattern is to version only the worker script but not the cache names. That is fragile because the script can update while caches remain ambiguous. A safer pattern is to encode release identity in both the worker and the cache name, then clean up anything that does not match the current release.

Also check whether your offline fallback is too broad. Serving the app shell for every failed request can mask real problems, especially if the test expects a backend error or redirect.

Use direct evidence from CI, not just local reproduction

The failure may only appear in CI because the runner is slower, the browser profile is reused differently, or the application hits a different network topology. Continuous integration environments can magnify timing bugs that a local laptop hides. See continuous integration for the basic model.

To debug effectively in CI, capture these artifacts:

  • browser console logs
  • page errors and uncaught exceptions
  • network traces or HAR files where possible
  • service worker registration state
  • screenshot or video at the point of failure
  • cache names and counts from the failing run

If your pipeline permits, add a dedicated job that runs the same spec twice, once against a clean profile and once against a warmed profile. That often makes update-related flakes obvious without requiring extra manual analysis.

A GitHub Actions step might also clear browser state between jobs to make the signal clearer:

- name: Run browser tests
  run: npm run test:e2e

If the issue disappears when state is cleared between jobs, the root cause is almost certainly persistence or update lifecycle, not selector instability.

Decide whether the test should wait, isolate, or assert differently

Not every failure needs the same remedy. A good fix depends on what the test is actually trying to verify.

Wait when the app behavior is correct but timing is off

If the app eventually becomes correct after service worker takeover, add a wait for the relevant lifecycle signal. That may be controllerchange, a version badge in the UI, or a known request completed after the new worker activates.

Isolate when stale state is not part of the test goal

If the test only validates an authenticated flow or a simple form submission, then old caches should not be relevant. In that case, start from a clean browser context, or clear storage explicitly.

Assert differently when offline fallback is expected

If your application legitimately serves cached offline content in certain situations, then your test should assert that behavior precisely rather than treating it as a failure. The important thing is that the test knows whether it is validating online behavior, offline behavior, or update behavior.

This is where many browser automation service worker problems come from, the test is trying to validate one contract while the app is intentionally switching to another.

A practical debugging checklist

Use this sequence when you hit a release-window failure:

  1. Confirm whether the failure only starts after a new service worker release.
  2. Determine whether the browser saw a waiting, installing, or active worker.
  3. Check whether the failing request came from cache or from the network.
  4. Compare clean-profile behavior with warm-profile behavior.
  5. Inspect cache names and delete logic for stale entries.
  6. Verify whether the app expects a controller change before the UI is stable.
  7. Confirm that offline fallback did not mask a real backend or routing issue.
  8. Re-run in CI with logs and traces captured.

If a test only fails after deploy, assume lifecycle timing first, selector fragility second, and network path ambiguity third. That ordering saves time.

Prevent the next flake before it reaches release day

The best time to debug service worker update failures is before the rollout window. A few design choices can reduce the probability of getting paged by your own test suite.

Keep update logic explicit

Do not hide worker update state entirely from the app. If the UI needs a reload to pick up a new version, surface that state clearly enough for tests and users to understand it.

Separate asset and API caching rules

Treat static bundles, navigation requests, and API responses as different categories. Conflating them increases the chance that a deploy will mix old and new state.

Add an update-path test

Have at least one end-to-end test that starts on version A, introduces version B, then verifies the app behaves correctly after activation. That test is not a replacement for your normal smoke suite, it is a targeted regression check for exactly this class of failure.

Run against real browsers

Because service workers are browser-managed, differences between engines matter. A pass in one browser does not guarantee identical cache or activation timing in another. If your team uses a real browser grid, keep the update-path test in the browsers your users actually run.

When to blame the app and when to blame the test

If a failure reproduces only when a new worker is installed, the app and the test may both be partly responsible. The app may be caching too aggressively, and the test may be assuming a state transition that is not guaranteed.

A useful distinction is this:

  • If the app breaks for real users after deployment, fix the worker lifecycle, cache policy, or update UX.
  • If the app is correct but the test makes timing assumptions, fix the test harness.
  • If both are brittle, do both, but start by making the application state transitions observable.

That approach turns a mysterious release-window failure into a traceable lifecycle problem. Once you can tell which worker version handled which request, the rest becomes much easier to reason about.

Browser tests fail after service worker update for a reason, not by chance. The worker changes the browser’s state model, and your automation needs to model that state too. When you can see activation, cache contents, and fallback behavior clearly, flakiness stops looking random and starts looking debuggable.