A browser test that passes until a service worker changes, then starts failing, is often telling you something narrower than “the app is broken.” The failure may come from a stale HTML shell, a precached JavaScript bundle, an offline fallback, or a test environment that reuses browser state across runs. The fastest way to sort that out is to separate product behavior from cached delivery behavior.

That distinction matters because a service worker sits between your page and the network. It can cache responses, serve offline fallbacks, and control when a new version becomes active. MDN’s service worker docs and the Service Workers specification are the right primary references for the lifecycle and caching model, while the Cache API explains what is actually stored and replayed.

If a test only fails after a deployment or bundle change, assume cache state first, application logic second.

The short answer: what to check first

When you see browser tests fail after service worker cache changes, start with four questions:

  1. Did the page load from cache instead of the network?
  2. Did an older service worker remain active?
  3. Did the app serve an offline fallback or stale shell?
  4. Did the test harness reuse browser profile data between runs?

If you can answer those questions quickly, you can usually decide whether you have a real regression or a test-environment problem.

A compact triage table

Symptom Likely cause First check
Old UI after deploy Cached HTML shell or precached asset Network panel, service worker scope, cache storage
Test passes in incognito but fails in CI Persistent browser profile state Clear profile, create a fresh context
Failure only after offline or flaky network simulation Offline fallback route Service worker fetch handler and fallback asset
Failure after service worker update Waiting/activating mismatch install, waiting, activate lifecycle events
Different result on hard refresh Browser cache or SW cache difference Disable cache, unregister worker, clear storage

Step 1, reproduce with cache disabled and a clean profile

Do not start by changing assertions. Start by removing cached state from the equation.

For Chromium-based automation, create a fresh browser context for each run, or at least for the failing suite. In Playwright, that means a new context, not a reused one.

import { test, expect } from '@playwright/test';
test('loads current UI', async ({ browser }) => {
  const context = await browser.newContext({
    serviceWorkers: 'block'
  });
  const page = await context.newPage();
  await page.goto('https://example.test');
  await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
  await context.close();
});

Two important notes:

  • serviceWorkers: 'block' helps determine whether the worker is the source of the flake, but it is a diagnostic move, not a permanent fix.
  • A fresh context is not the same thing as a new tab. A reused profile can keep cookies, cache storage, IndexedDB, and service worker registrations alive.

If you are using Selenium or another framework, the principle is the same, even if the API differs: create a clean browser profile, not just a clean page.

Also disable browser cache when you can

For manual debugging or low-level automation, disabling the browser cache helps separate network freshness from service worker behavior. In Chrome DevTools, the Network panel has a cache-disable option while DevTools is open. That is not identical to clearing service worker cache storage, but it is still useful as a quick check.

Step 2, inspect the service worker lifecycle, not just the page error

A lot of browser test flakiness comes from misunderstanding the lifecycle. A new worker can be installed but not activated yet. An older worker can continue controlling existing pages until clients reload. That means your test may be exercising a different code path than the one you expect.

Watch for these events in your app or a debug build:

  • install, new worker is being installed
  • waiting, a new worker is ready but not active yet
  • activate, new worker has taken control
  • controllerchange, the controlling worker for the page changed

If you have access to the page code, a lightweight logger can make hidden state visible during test runs.

if ('serviceWorker' in navigator) {
  navigator.serviceWorker.addEventListener('controllerchange', () => {
    console.log('service worker controller changed');
  });

navigator.serviceWorker.getRegistration().then((reg) => { console.log(‘active:’, !!reg?.active, ‘waiting:’, !!reg?.waiting); }); }

This is especially useful when a test fails only after a redeploy. If waiting is true but controllerchange never happens, the page may still be using old assets.

Step 3, identify what kind of cached asset is actually stale

Not every cache problem is the same. Separate the asset types before you guess at the fix.

Cached HTML shell

Many PWA-style apps serve a cached HTML shell first, then hydrate with JavaScript. If the shell is stale, the whole page can appear “half updated.” The symptom is often a mismatch between visible UI and network payloads.

What to check:

  • Does the initial document come from the service worker?
  • Is the HTML referencing old bundle names or old manifest data?
  • Is the app reading versioned config from a cached bootstrap file?

Precached assets

Precache manifests often store JavaScript, CSS, fonts, and icons. If a new deployment changes a bundle name or a manifest entry and the test still gets the old response, the failure may be caused by a stale precache rather than application logic.

What to check:

  • Are hashed asset names changing as expected?
  • Does the service worker precache list match the deployed build?
  • Is the browser serving a response from Cache Storage instead of the network?

Offline fallback behavior

Some workers intentionally return fallback content when the network fails. That can be correct product behavior, but it can also hide a broken network path in tests.

What to check:

  • Does the fetch handler return a fallback on failed navigation requests?
  • Is the fallback page causing assertions to fail because the test expected a live page?
  • Are your tests simulating offline without accounting for that route?

Step 4, look at the network and cache storage together

A browser test can pass visually while still relying on a stale response. Use DevTools or automation logs to inspect both network and cache state.

In Chromium DevTools, the key places are:

  • Network tab, to see whether the response was served from cache or network
  • Application tab, to inspect service worker registrations and Cache Storage
  • Console, to catch lifecycle logs and runtime warnings

For automation, you can also inspect the browser context state after the page loads. If your framework supports it, log the current service worker registration and the URL that supplied the main document.

A passing assertion is not proof that the right asset loaded. It only proves the DOM matched your expectation at that moment.

Step 5, clear the browser-state layers that actually matter

“Clear cache” is too vague. Service worker debugging often requires resetting multiple browser-state layers.

Prioritize these, in order:

  1. Service worker registrations for the origin
  2. Cache Storage entries for the origin
  3. Cookies and local storage if auth or feature flags are involved
  4. IndexedDB if the app reads boot state from it
  5. Browser profile data reused across test runs

The exact reset mechanism depends on the framework. For local debugging in Chrome, the Application panel can unregister workers and clear site data. In CI, use a clean browser context or a disposable profile directory.

If you are running Playwright against a PWA-like app and suspect reused origin state, a test fixture can make the reset explicit:

import { test } from '@playwright/test';

test.beforeEach(async ({ context }) => { await context.clearCookies(); await context.addInitScript(() => { localStorage.clear(); sessionStorage.clear(); }); });

That does not remove service worker registrations by itself, which is the point. If the failure disappears only after a full origin reset, the worker remains a leading suspect.

Step 6, decide whether the app or the test harness is at fault

This is the judgment step, and it should be explicit.

Suspect the app when

  • the same stale asset appears in a real browser session and in automation
  • the worker update path never activates the new version
  • the offline fallback is returned for a request that should have been online
  • the app code assumes immediate rollout, but the worker keeps older clients alive

Suspect the test harness when

  • the failure appears only in reused browser profiles
  • the test environment blocks or delays service worker lifecycle events
  • a parallelized suite leaks origin state between tests
  • a test depends on network timing that becomes different once caching is enabled

The best separating test is usually simple: run the same script once with service workers blocked, once with a clean profile, and once with normal browser settings. If only the normal cached run fails, you have strong evidence that the issue is in caching behavior or lifecycle handling, not core app logic.

Common edge cases that create false alarms

Versioned assets changed, but the HTML shell did not

This often happens when deployment updates are only partial, or when the worker caches the shell more aggressively than the bundles. The page may boot with a stale manifest that points at files no longer present.

A worker update is installed but not activated

The new worker may be waiting for existing tabs to close. Tests that reuse a long-lived session can keep using old behavior until the worker takes control.

A test asserts on text that is generated after hydration

If hydration is slow or the worker serves a different bundle, the page can render a placeholder, then update later. The assertion may fail simply because the test clicked or read too early.

Offline mode is simulated, but the test still expects online content

That is not a cache bug. It is a test design mismatch. If the app has offline fallback behavior, the assertion should match the offline contract.

A practical debugging flow you can reuse

When a flaky browser test is tied to service worker cache changes, use this order:

  1. Reproduce in a fresh profile or context.
  2. Run once with service workers blocked.
  3. Compare network responses to Cache Storage behavior.
  4. Inspect worker lifecycle events, especially waiting and controllerchange.
  5. Clear origin state layers, not just cookies.
  6. Decide whether the bug is in deployment, cache invalidation, or test isolation.

If steps 1 to 3 point to cached assets, the fastest fix is often on the app side, not the test side. If steps 1 to 3 only fail in reused profiles or parallel suites, then the harness needs better isolation.

What to change in the suite after you find the cause

Once you identify the source, make the fix durable:

  • Use a fresh browser context per test or per isolated scenario.
  • Avoid depending on stale bootstrap data.
  • Add explicit waits for app readiness instead of DOM presence alone.
  • Log service worker version or build hash in debug runs.
  • Treat offline tests as a separate scenario from online regression checks.

For teams using browser automation at scale, this is the real maintenance win. Fewer tests should depend on “whatever happens to be cached right now.”

When this is probably not a service worker problem

Do not overfit the diagnosis. If the failure is only about selector changes, timing on an API response, or a true JavaScript exception in fresh sessions with caching disabled, then service worker cache is not your primary issue. The same is true if the app has no service worker at all, or if the worker is irrelevant to the failing route.

In other words, service worker debugging is a strong hypothesis, not a universal explanation.

FAQ

How do I know if a page is controlled by a service worker?

Check navigator.serviceWorker.controller in the page, or inspect the Application tab in Chrome DevTools for active registrations on the origin.

Is clearing cookies enough to remove service worker flakiness?

No. Cookies are only one layer of state. Cache Storage, service worker registrations, local storage, session storage, and IndexedDB can all affect the result.

Why does the test pass in incognito but fail in CI?

Incognito usually starts from a cleaner browser state. CI often reuses profiles, caches, or session data unless the job explicitly creates a fresh context or profile.

Should I block service workers in all tests?

Not by default. Block them for diagnosis or for suites that should verify server-rendered behavior without cache interference. Keep at least one path that validates the app with service workers enabled if your production users rely on them.

What is the most reliable first fix for stale offline assets?

Make the failure reproducible with a clean browser context and compare that run with a normal cached run. That tells you whether to fix invalidation, activation, or test isolation first.