Intermittent browser failures are frustrating because they look random but usually are not. A test passes on a clean local profile, fails in CI, then passes again on rerun. The app code did not magically become unstable. More often, the problem sits in client storage layers that survive longer than the test author expected, especially sessionStorage, IndexedDB, and service worker cache.

These layers are useful in production, but they are also excellent at hiding state between test runs. If you are debugging browser test failures caused by session storage IndexedDB and service worker cache, the practical question is not “how do I clear everything blindly?” It is “what do I log so I can prove which layer kept the wrong state, when it was written, and which code path consumed it?”

That distinction matters. A blanket clear can make a flaky test pass without teaching you anything. Better logging gives you a repeatable failure analysis path and a way to separate application bugs from test setup bugs and environment contamination.

Why these three storage layers cause test flakiness

Each layer has a different lifetime and different failure shape:

  • sessionStorage is scoped to the tab or top-level browsing context. It is often used for one-step wizards, auth handoff state, feature flags, or request correlation.
  • IndexedDB is persistent structured storage. Apps use it for offline data, caches, user drafts, retry queues, and app bootstrap state.
  • service worker cache sits behind the network stack and can serve stale assets or stale API responses depending on implementation.

The key debugging problem is that all three can make a test look nondeterministic even when the DOM assertions are correct. For example, the UI may render differently because a hydration script came from an old service worker cache, because IndexedDB still contains a previously seeded entity, or because sessionStorage still has a flag that skips a tutorial modal.

If a test fails only on rerun or only in CI, suspect hidden client state before you suspect selector instability.

The common theme is state that lives outside the test’s main control flow. That makes browser storage debugging a logging problem as much as a cleanup problem.

Start by classifying the symptom before you add logs

Before instrumenting everything, note what changed between pass and fail:

1. Does the failure happen after login or navigation?

That often points to sessionStorage or service worker behavior. A route guard or boot script may read session values immediately after load.

2. Does the failure involve missing or duplicate records?

That often points to IndexedDB. The app may think a draft, cache entry, or local entity already exists.

3. Does the failure change after a hard reload or cache disable?

That often points to service worker cache invalidation or stale assets.

4. Does the failure happen only in reused browser contexts?

That suggests storage leakage between tests or insufficient isolation in your CI configuration.

This first pass is important because the logs you collect should answer a specific question. A log dump without a hypothesis is usually noise.

What to log for sessionStorage

sessionStorage is small, but it is often the easiest place for test-specific state to hide. Log the following at failure time and, when useful, around navigation boundaries.

Log the key list, not just a single value

A common mistake is to inspect one known key while missing a sibling key written by another code path. Collect the key list and a filtered value snapshot.

typescript

const sessionDump = await page.evaluate(() => {
  const data: Record<string, string> = {};
  for (let i = 0; i < sessionStorage.length; i++) {
    const key = sessionStorage.key(i)!;
    data[key] = sessionStorage.getItem(key) || '';
  }
  return data;
});
console.log('sessionStorage', sessionDump);

Log writes, not just final state

Final state can hide a race. If a modal reads a key before your test seeds it, the failure may be timing-related rather than data-related. Instrument application code in test builds, or use a page-level hook if your app exposes one. Capture:

  • key name
  • value shape or truncated value
  • timestamp relative to navigation start
  • call site or event name when available

If you cannot instrument the app, capture the state immediately before and after the action that is supposed to set it.

Log navigation boundaries

sessionStorage survives reloads in the same tab, but not cross-tab boundaries. That matters when your test uses new pages, popups, or redirects. Log:

  • initial URL
  • performance.now() at the beginning of the test step
  • whether the page was reused
  • whether the test created a new context, page, or tab

Common failure modes for sessionStorage

  • A wizard completion flag was left behind from a prior test in the same context.
  • A login flow sets a redirect target that is consumed on the next route.
  • A feature flag in sessionStorage changes the control path in CI but not locally.
  • A test opens a new tab and expects sessionStorage to be shared, but it is not.

sessionStorage bugs often masquerade as UI timing bugs because the app reads them during boot, before your assertions start.

What to log for IndexedDB

IndexedDB failures are often harder to see because the storage is structured and asynchronous. Tests may pass with empty storage and fail only when old records remain.

Log database names, object stores, and counts

At minimum, log which databases exist and what stores they contain. If the failure is tied to a specific record type, count records rather than dumping everything.

typescript

const indexedDbSummary = await page.evaluate(async () => {
  const databases = await indexedDB.databases?.() ?? [];
  return databases.map((db: any) => ({ name: db.name, version: db.version }));
});
console.log('indexedDB databases', indexedDbSummary);

The indexedDB.databases() API is useful for triage, but support varies across browsers. When it is missing, fall back to app-specific inspection if possible.

Log schema version and migration state

A frequent source of intermittent failures is a schema migration that only runs on some machines or only after certain cache states. Log:

  • database version expected by the app
  • whether a migration flag exists
  • whether the app wrote a new schema version during the run
  • any migration errors, even if the UI recovered

If your app stores version markers in localStorage or IndexedDB, include both.

Log record identity and staleness markers

When the test fails because “the wrong record is already there,” capture stable identifiers, not entire payloads:

  • record ids
  • timestamps
  • status fields
  • source markers such as seeded, synced, offline, or draft
  • counts of related entities

This lets you distinguish between stale data, duplicated seed data, and a business logic bug that genuinely created the wrong entity.

Log the cleanup path separately

Do not assume deleteDatabase() succeeded just because the test continued. Capture the promise outcome and any blocking connections.

typescript

const dbDeleteResult = await page.evaluate(async () => {
  try {
    const req = indexedDB.deleteDatabase('app-db');
    return await new Promise((resolve, reject) => {
      req.onsuccess = () => resolve({ ok: true });
      req.onerror = () => reject(req.error?.message || 'delete failed');
      req.onblocked = () => resolve({ ok: false, blocked: true });
    });
  } catch (e) {
    return { ok: false, error: String(e) };
  }
});
console.log('indexedDB delete', dbDeleteResult);

Common failure modes for IndexedDB

  • A stale record from a previous test changes the app into an alternate branch.
  • A migration runs once locally but not in CI because the browser profile is reused differently.
  • The app opens a connection that blocks database deletion.
  • Seed data races with app boot, so the UI reads old data before the new fixture is written.

What to log for service worker cache

Service workers are a frequent source of browser test failures because they can serve old JavaScript bundles, old CSS, or cached responses that no longer match the current server state. This is especially painful when a test passes after a full browser restart but fails during the same CI job.

Log whether a service worker is controlling the page

This is the first checkpoint.

typescript

const swState = await page.evaluate(async () => {
  const registration = await navigator.serviceWorker.getRegistration();
  return {
    controller: !!navigator.serviceWorker.controller,
    scope: registration?.scope || null,
    state: registration?.active?.state || null,
    updateViaCache: registration?.updateViaCache || null,
  };
});
console.log('service worker', swState);

If controller is true on a page that should be fresh, you may be testing against cached assets without realizing it.

Log cache names and request matching behavior

If your app uses the Cache API, record cache names and a few representative keys. This helps identify mismatched build versions or stale response entries.

typescript

const cacheSummary = await page.evaluate(async () => {
  const names = await caches.keys();
  return names;
});
console.log('cache names', cacheSummary);

If you have a versioned cache naming convention, capture the version string. It often tells you whether a failed run loaded a newer server build with an older client bundle, or the reverse.

Log response provenance when possible

For failing network-dependent tests, annotate whether a response came from the network, the service worker, or a browser cache layer. In Playwright, you can log request and response URLs around the relevant navigation and API call. You are not trying to reproduce Chrome DevTools in code, just to answer: did the app see fresh data?

Common failure modes for service workers

  • A stale worker serves an older JavaScript bundle after deployment.
  • A cache-first strategy returns old API data in CI even though the backend is correct.
  • A worker update is waiting, but the test never reloads, so old code remains active.
  • A test depends on network stubbing, but the worker intercepts the request before the stub does.

Service worker bugs are often deployment bugs wearing a test-flakiness costume.

A logging checklist that is actually useful in CI

You do not need infinite logs. You need a compact failure packet that can answer the main questions in one look.

For each failure, capture:

Environment and browser context

  • browser name and version
  • CI job id and shard id
  • whether the test reused a browser context or page
  • viewport and device emulation, if relevant
  • application build id or git SHA

Storage state

  • sessionStorage key list and values, truncated
  • IndexedDB database names, versions, and relevant counts
  • service worker control state, scope, and cache names

Timing and order

  • timestamps for page load, storage seed, user action, and failure
  • whether storage cleanup happened before app bootstrap or after
  • whether a navigation, reload, or tab switch occurred

Network and cache context

  • whether the page loaded from a fresh deploy or a cached asset path
  • representative request URLs and status codes
  • any request interception or route mocking used by the test

Error context

  • the exact assertion failure
  • console errors
  • page errors
  • uncaught promise rejections
  • any schema or migration errors from app code

The reason for this list is simple. A flaky test often fails because two things happen in the wrong order, not because one thing is bad in isolation.

A practical Playwright debugging pattern

In Playwright, the easiest way to miss storage bugs is to create a fresh page and assume that means a fresh app. It does not always mean that, especially if your CI setup reuses contexts, persistent profiles, or service worker state.

This pattern gives you a small failure packet without overengineering your framework.

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

test.afterEach(async ({ page }, testInfo) => { if (testInfo.status !== testInfo.expectedStatus) { const storage = await page.evaluate(async () => ({ session: Object.fromEntries( Object.entries(sessionStorage) ), swControlled: !!navigator.serviceWorker.controller, caches: await caches.keys(), })); console.log(JSON.stringify(storage, null, 2)); } });

This is not a production-grade logger. It is a debugging aid. Keep it small enough that engineers actually leave it in place during the investigation.

How to separate app bugs from test bugs

A good storage investigation ends with a classification, not just a workaround.

Treat it as a likely app bug when

  • storage state is valid but the app reads the wrong key or stale record
  • a migration silently leaves old data in place
  • the service worker serves the wrong build or stale API response
  • the UI changes based on persistence logic that is undocumented or inconsistent

Treat it as a likely test bug when

  • the test assumes state isolation that the browser does not provide
  • cleanup happens after the app has already booted
  • the test reuses a context without resetting storage
  • the test depends on implementation details that change across releases

Treat it as an environment issue when

  • CI and local browsers differ materially in profile reuse
  • sharding changes the order of shared-state tests
  • the service worker persists across runs in the same runner VM
  • a browser update changes cache or storage behavior

This classification matters because the fix is different. App bugs should be fixed in application logic or migration behavior. Test bugs should be fixed in setup, teardown, isolation, or fixture strategy. Environment issues usually need runner-level changes or explicit profile reset.

Cleanup strategies, ranked by how much they tell you

Not every cleanup strategy is equal.

Best for diagnosis, isolate by context

Create a new browser context per test or per stateful suite. This costs more runtime, but it tells you whether the failure depends on persisted client state.

Good for shared environments, reset known state

Clear only the storage your app uses, then verify that cleanup succeeded before app bootstrap.

Last resort, nuke the profile

Delete the browser profile or worker workspace when you need a clean slate. This is effective, but it can hide the exact cause if used too early.

The tradeoff is cost versus clarity. Fully isolated contexts consume more CI time and memory. Targeted cleanup is cheaper, but only if you know which storage layer caused the failure.

A small decision tree for triage

Use this when a test is failing intermittently and you need to decide where to look first:

  1. Did the failure follow a rerun or a reuse of the same browser context?
    • Yes, inspect sessionStorage and IndexedDB.
  2. Did a reload or hard refresh change the result?
    • Yes, inspect service worker control and caches.
  3. Did the failure disappear after clearing only one storage layer?
    • Yes, log that layer before the next run and verify order of operations.
  4. Did the failure survive full profile reset?
    • Yes, the problem may be in app logic, backend data, or network stubbing rather than client persistence.

The fastest path is rarely “clear everything.” It is usually “find the narrowest layer that explains the symptom.”

What a good failure report should contain

When someone on your team opens a flaky-test ticket, the report should include enough information to avoid a second round of guessing:

  • exact test name and branch or commit
  • whether it failed on first run, rerun, or only in CI
  • storage state snapshots at failure time
  • service worker and cache state
  • relevant network requests or mocked routes
  • whether the app had already navigated, reloaded, or opened a new tab
  • the minimal step that changed the outcome

If you can attach a single artifact, make it the storage snapshot and the timing around the failure. That is usually more useful than a full-page screenshot when the bug is in hidden state rather than the visible DOM.

Final takeaway

When browser tests become intermittent, the hard part is not only clearing state, it is learning which state mattered. sessionStorage can steer startup logic, IndexedDB can preserve stale business data, and service worker cache can serve an older application than the one your CI job just built. Those are different failure modes, and they deserve different logs.

If you log the storage keys, schema versions, cache control state, and timing around writes and reads, you turn a vague flaky-test complaint into a traceable sequence of events. That is the difference between repeatedly resetting environments and actually fixing the cause.

For teams building a durable browser automation practice, that discipline is part of broader software testing, test automation, and continuous integration. The tools matter, but so does the habit of collecting the right evidence before changing code.

If you want a starting point for your next investigation, add a small failure packet that captures sessionStorage, IndexedDB summaries, and service worker control state. That alone will eliminate a surprising amount of guesswork in browser storage debugging.