The failure usually looks nonsensical at first: step 1 passes, step 2 passes, then an element disappears, a request shape changes, or a route suddenly behaves like a different release. The test did not get worse on its own. The page likely moved from one app version to another while the session was still open.

That version skew is the core problem. A service worker can refresh cached assets, swap in a new bundle, or serve a different response path after the test has already started. If your test assumes one build from first click to final assertion, a mid-session update turns that assumption into flakiness.

The debugging goal is not just to make the test pass. It is to prove which app version the browser saw at each step, then decide whether the fix belongs in the app, the caching strategy, or the test setup.

What is actually failing here?

A service worker update is not the same thing as a normal page reload. The worker can install a new version in the background, then take control on the next navigation or when the active client changes, depending on your app logic and browser behavior. That means a test can start on build A and finish on build B without an obvious reload event in your test code.

This is different from a simple cache bug. A cache bug usually means stale or inconsistent resources are being served. A version mismatch during tests means the UI, the JavaScript bundle, and sometimes the API contract no longer agree with each other because the active app version changed mid-flow.

Symptoms that point to version skew

Look for these patterns first:

  • A selector disappears only after a navigation or modal transition
  • The same request payload succeeds earlier in the run but fails later
  • A button exists, but its label, DOM structure, or route changed mid-test
  • A test intermittently gets logged out or lands on an onboarding step it did not expect
  • Network traces show a newer JS bundle or different asset hash than the one loaded at startup
  • A release-specific feature gate appears halfway through the session

If you see these symptoms, do not start by adding waits. Waits can hide timing problems, but they do not solve app version mismatch.

First rule, log the build identity everywhere

You cannot debug what you cannot version. Add a build identifier to every test run and make it visible in both browser logs and app logs.

Useful values include:

  • Git commit SHA
  • frontend build hash
  • service worker version or revision
  • asset manifest version
  • API version header if the backend sends one

A good target is to capture the same identifier in three places:

  1. In the rendered page, for example a hidden meta tag or a debug footer
  2. In the browser automation logs
  3. In the network layer, as a response header or JSON field

If your app already exposes a version in a header or a global object, use that. If not, add one. A tiny amount of instrumentation is much cheaper than repeated triage of flaky test runs.

Minimal logging pattern

Here is a simple Playwright-style pattern for collecting version data from the page and the network.

const version = await page.locator('meta[name="app-version"]').getAttribute('content');
console.log('app-version:', version);

page.on(‘response’, async (response) => { if (response.url().includes(‘/api/’)) { const headerVersion = response.headers()[‘x-app-version’]; if (headerVersion) console.log(‘api-version:’, headerVersion); } });

The exact mechanism is less important than consistency. The key is to be able to answer, for every failing run, “What version was this page when the failure happened?”

Separate cache behavior from application regressions

Before you touch the test itself, decide whether the bug is in caching or in the app.

A cache or service worker problem usually shows up as:

  • Old JavaScript running against new HTML, or the reverse
  • Stale assets after a deployment that should have been replaced
  • Update behavior that changes when service workers are disabled
  • Flakiness that vanishes in a clean browser profile

An application regression usually shows up as:

  • The same failure in a fresh profile, with service workers disabled, and after cache clearing
  • Consistent breakage on a known release hash
  • The same failure in manual testing and in automation

That distinction matters because the remediation path is different. Cache bugs push you toward service worker lifecycle control, cache-busting rules, and release coordination. App regressions push you toward normal product debugging.

Reproduce with a clean profile before you change the test

The fastest way to remove ambiguity is to reproduce the failure in a controlled browser state.

Try these three runs:

  1. Fresh profile, no existing service worker, no cache
  2. Warm profile, with service worker registered and cache populated
  3. Warm profile plus deployment swap, where you simulate an app update during the session

If the failure only appears in the third case, you have version-skew flakiness, not a deterministic UI bug.

With Chrome DevTools Protocol based tooling, you can inspect service workers and storage, but do not assume that a page-level JavaScript restriction equals an automation restriction. The browser can let the framework clear state even when page code cannot.

For browser automation suites, Cypress and similar frameworks expose ways to manage browser state between tests, but the exact control varies by tool. If your runner also supports a cloud browser grid such as BrowserStack, make sure the remote environment is not reusing storage in a way that hides the issue.

Make the update visible in the test log

Once you can reproduce it, instrument the update path itself.

Log these events if your app allows it:

  • service worker install
  • service worker activate
  • controller change
  • app boot version
  • navigation start and navigation end

If you own the service worker code, emit a lightweight debug message during development builds or to a test-only endpoint. Keep it readable. The goal is not verbose telemetry, it is being able to line up state changes with test steps.

Example: inspect the service worker state

const swState = await page.evaluate(async () => {
  const reg = await navigator.serviceWorker.getRegistration();
  if (!reg) return 'no-registration';
  return {
    installing: !!reg.installing,
    waiting: !!reg.waiting,
    active: !!reg.active,
    controller: !!navigator.serviceWorker.controller
  };
});
console.log(JSON.stringify(swState));

A waiting worker with an active controller often means an update is queued but not yet controlling the page. That is exactly the kind of state that can surprise a long-running test.

Pin the test to one app version when you can

If the test is meant to verify product behavior, not update behavior, then pin the app version for the duration of the run.

Useful tactics include:

  • Disable service worker registration in test builds
  • Serve assets with versioned filenames and immutable caching rules
  • Reset browser storage before each test
  • Use a dedicated test host or environment where deployments do not happen mid-run
  • Block auto-update triggers until the suite finishes

A stable suite should not need to survive arbitrary app swaps unless the specific scenario is about update handling.

Good default for most UI flows

For login, checkout, settings, and workflow tests, I would start with this rule:

If the test is not explicitly about update behavior, prevent service worker takeover during the run.

That can be done by using a non-PWA test build, a debug flag, or a pre-test cleanup step. The exact technique depends on your stack, but the objective is the same: one test run, one app version.

If you need to test updates, make the update explicit

Some teams do need to validate the update path. In that case, do not let the service worker update happen by accident. Force it deliberately and assert the transition.

Test the following sequence:

  1. Start on version A
  2. Confirm version A is active
  3. Trigger the update check or deploy version B
  4. Confirm the waiting worker appears
  5. Reload or re-enter the flow in a controlled way
  6. Confirm the app restarts on version B

This makes the boundary visible and removes ambiguity about where the failure happened.

Decide whether to restart, reload, or reset storage

When a test fails because of version skew, there are three broad fixes. Pick the smallest one that matches the product behavior.

Situation Best fix Why
Test should never cross versions Disable SW update or clear state before run Keeps the flow deterministic
Test must verify update behavior Explicitly trigger and assert the update boundary Makes version transition part of the test
Test fails because stale state leaked between cases Reset browser storage and service worker registrations Removes cross-test contamination

Do not use a full browser restart as the default answer unless the stack makes lighter cleanup impossible. It can hide the actual bug and slow the suite down.

A quick triage checklist

Use this sequence when a browser test starts failing after a deployment:

  1. Capture the build hash from the page and network
  2. Check whether the service worker changed state during the test
  3. Re-run in a fresh profile
  4. Re-run with service workers disabled or bypassed
  5. Compare request/response headers between passing and failing runs
  6. Confirm whether the failure is tied to one version or to the update boundary

If steps 3 and 4 make the failure disappear, you are dealing with cache or update coordination, not a stable UI defect.

Failure modes that waste the most time

Adding generic waits

A longer wait may let the update finish before the assertion runs, which can make the failure disappear without fixing the underlying issue. That is a false win.

Retrying the same broken flow

Retries are useful for transport noise, but repeated retries on version-skew failures just create more mixed-version runs.

Clearing only local storage

Local storage cleanup does not necessarily remove service workers, caches, or indexed data. If the worker is the problem, clear the worker and its caches too.

Assuming the backend changed first

Sometimes the backend is innocent. The worker may have swapped in a new shell or asset bundle while the API remained stable. Look at both layers before assigning blame.

A practical debugging script for state isolation

If your runner supports it, start every relevant test with state cleanup like this:

await page.context().clearCookies();
await page.context().clearPermissions();
await page.evaluate(async () => {
  const regs = await navigator.serviceWorker.getRegistrations();
  await Promise.all(regs.map(reg => reg.unregister()));
  const keys = await caches.keys();
  await Promise.all(keys.map(key => caches.delete(key)));
});

This is not a universal fix, but it is a strong baseline for proving whether the failure depends on persistent browser state.

When to change the app, not the test

Sometimes the test is only exposing a real product problem. If users can see broken behavior during a normal update, the app needs better update coordination.

Consider changing the app if:

  • The worker can activate in the middle of a critical transaction
  • The UI does not guard against version mismatch between shell and API
  • A partial refresh can leave the app in an inconsistent state
  • The update notice is silent or hard to detect

In those cases, adding test workarounds only hides a release risk. The product should either postpone activation, prompt the user, or force a controlled refresh at a safe boundary.

A simple rule for stable browser automation

If a test cares about business logic, pin the app version. If a test cares about update behavior, make the update explicit. If a test fails unexpectedly, log the build hash before you touch the wait strategy.

That order matters because browser automation flakiness from service worker updates is usually a state problem first and a timing problem second.

FAQ

How do I know a service worker updated the app mid-session?

Check the build hash, the service worker registration state, and the asset version loaded by the browser. If those change during a single run, you have version skew.

Should I disable service workers in all tests?

No. Disable or bypass them for flows that should remain on one build. Keep a separate test path if you need to verify update behavior.

Is cache clearing enough to fix the issue?

Not always. Service worker registrations, caches, cookies, and indexed storage can all affect the session. Clear the specific state that causes the mismatch.

What is the difference between a cache bug and an app regression?

A cache bug changes what assets the browser receives. An app regression breaks the product behavior even in a clean, stable version. The reproduction steps usually reveal which one it is.

What should I log first when a test starts failing after deployment?

Log the frontend build hash, the service worker state, and the network response version. Those three signals usually tell you whether the run crossed versions.