How to Tell Whether Browser Test Failures Come From App Bugs or Leaked Browser State
By Luca Müller · August 15, 2026
Learn how to separate real app regressions from browser test failures caused by leaked browser state, including cookies, localStorage, IndexedDB, service workers, caches, and reused profiles.
A browser test failure is not automatically a product bug. In CI, the same symptom can come from the app, the test, or a leftover browser state layer such as cookies, localStorage, IndexedDB, service workers, cached assets, or a reused profile. The practical question is simple: does the failure follow the application, or does it disappear when the browser starts clean?
If you are seeing browser test failures from leaked browser state, the fastest path is to isolate the state layer before you spend time chasing selectors, waits, or product code. That matters because state leakage often creates session persistence flakiness, cookie carryover in browser tests, and reused browser profile issues that only show up after a few tests, a retry, or a parallel run.
What counts as leaked browser state?
Browser automation can carry state across tests in several places:
- Cookies, often used for auth, A/B flags, CSRF state, or locale preferences
- localStorage and sessionStorage, common for tokens, onboarding flags, feature toggles, and UI preferences
- IndexedDB, often used by modern apps, offline data layers, or cached domain models
- Service workers and Cache Storage, which can keep serving older assets or responses
- Reused browser profiles, which preserve a mixture of the above across tests or runs
If a test passes only when run alone, and fails after another test, the first suspect is usually state leakage, not the application.
Fast decision tree
Use this decision tree to separate app regressions from browser-state pollution.
text Failure happens in CI or locally | +– Does it fail in a brand-new browser context with no reused profile? | | | +– yes -> look at app bug, test logic, or environment | +– no -> state leak is likely | +– Does the failure disappear if you clear cookies, storage, cache, and service workers? | +– yes -> leaked browser state is the cause +– no -> inspect app behavior, timing, backend data, or selectors
A more useful version adds one extra step:
- Re-run the test in an isolated browser context.
- Clear persistent browser data.
- Reproduce the same user journey from a clean account.
- Compare network calls, DOM state, and storage contents.
- Only then decide whether the app changed.
Symptom table: what the failure usually points to
| Symptom | More likely cause | Why it happens |
|---|---|---|
| Test passes in a fresh profile but fails in CI retries | Reused browser profile issues | Old cookies or storage survive between runs |
| Login succeeds, but later requests are unauthorized | Cookie carryover in browser tests or stale auth tokens | App reads an expired session from storage |
| Page looks old after a deployment | Cached assets or service worker cache | Browser serves stale JS or HTML |
| Feature flag behavior changes between tests | localStorage leakage | A prior test set a flag that changes app flow |
| Intermittent data mismatch after reload | IndexedDB or service worker state | Offline cache or persisted model data is reused |
| Test fails only after another test in the same worker | Shared browser context | One test polluted state used by the next test |
| Failures stop when storage is cleared manually | Browser state leak | The app was not the root cause |
The isolation steps that settle the question
1) Run the test in a brand-new browser context
A brand-new context or incognito-style session is the cleanest first check. In Playwright, for example, a fresh context gives you isolated cookies, storage, and cache by default.
import { test, expect } from '@playwright/test';
test('example', async ({ browser }) => {
const context = await browser.newContext();
const page = await context.newPage();
await page.goto('https://example.com');
await expect(page).toHaveTitle(/Example/);
await context.close();
});
If the failure disappears here, do not stop at “it is flaky.” That usually means the test suite is sharing state somewhere else.
2) Clear the browser layers explicitly
If you are debugging a persistent profile, clear the layers one at a time so you can identify the source.
What to clear:
- Cookies
- localStorage and sessionStorage
- IndexedDB databases
- Service workers
- Cache Storage
- Browser profile directory, if the suite reuses one
For Chromium-based tools, the exact mechanics differ by framework, but the principle is the same: clear the persistent data the browser can reuse.
3) Compare storage before and after the failing action
A failure often becomes obvious when you inspect what changed before the failure.
Useful checks include:
- Did a cookie appear or change unexpectedly?
- Did localStorage gain an auth token, experiment flag, or onboarding marker?
- Did IndexedDB keep old data after logout?
- Did a service worker stay registered after the test thought it was removed?
If a test expects a logged-out state but still sees an auth cookie, the failure is usually test pollution, not a server bug.
4) Reproduce with a second account or a new backend fixture
A clean browser is only half the story. You also want a clean application state. If the same failure occurs with a new browser profile and a fresh test account, then the problem is more likely in app behavior or test data setup.
This is especially important for flows that depend on backend fixtures, account roles, or feature flags. A browser state bug can look like a product regression when the real issue is a stale token or local cache of user data.
5) Disable retries while debugging the first failure
Retries can hide the real symptom. If the first run leaves behind state that makes the retry pass or fail differently, you lose the signal. For debugging, use one run, one browser context, one worker if possible.
How each state layer causes flaky browser tests
Cookies
Cookies are the most common cause of session persistence flakiness. They can preserve login state, locale, consent settings, CSRF tokens, or server-side experiments. If one test logs in and another expects an anonymous session, cookie carryover in browser tests can make the second test nondeterministic.
Typical failure mode:
- Test A logs in and sets a session cookie.
- Test B runs in the same profile and unexpectedly starts authenticated.
- The app behaves correctly, but the test expectation is wrong because state leaked.
localStorage and sessionStorage
Local storage leakage is common in apps that store tokens, onboarding completion, theme settings, or feature toggles there. sessionStorage is scoped to a tab, but it still leaks when a test reuses the same tab or navigates in a way the suite did not expect.
Common clue:
- The UI opens in a different mode than a new user would see.
- A banner, wizard, or experiment variant is skipped.
IndexedDB
IndexedDB bugs are harder to spot because the data is structured and persistent. Modern frontend apps use it for offline caching, sync queues, search indexes, or application state.
Failure clue:
- The UI shows stale business data even after hard reloads.
- Clearing cookies does nothing, but deleting the browser profile fixes it.
Service workers and cached assets
A service worker can keep serving an old JavaScript bundle or cached API response. This is one of the most confusing sources of browser test failures because the app code and the test code may both be correct, but the browser is executing an old version.
A good sanity check is to compare the asset version or application build hash the browser loaded against the deployed version.
Reused browser profiles
Reused browser profile issues are the umbrella problem. If the profile persists across tests, you are effectively turning browser automation into a long-lived session machine. That can speed up some suites, but it also creates hidden coupling and makes failures order-dependent.
A simple triage workflow for CI
When a test fails in CI, use the same order every time:
- Re-run once with a fresh context.
- Disable profile reuse for the failing job.
- Clear storage and cache before navigation.
- Capture storage snapshots in the failing and passing cases.
- Inspect network responses for auth, feature flags, and stale asset versions.
- Compare against a clean test account.
A lightweight Playwright helper can make this repeatable.
typescript
async function clearState(page) {
await page.context().clearCookies();
await page.evaluate(async () => {
localStorage.clear();
sessionStorage.clear();
const dbs = await indexedDB.databases();
await Promise.all(dbs.map(db => db.name && indexedDB.deleteDatabase(db.name)));
if ('caches' in window) {
const keys = await caches.keys();
await Promise.all(keys.map(key => caches.delete(key)));
}
});
}
Use this as a debugging aid, not as a permanent substitute for correct test isolation. If you need this helper in every test, the suite design likely needs a cleanup strategy at the worker or context level.
When it is probably an app bug instead
Not every failure is state pollution. Treat it as a product regression when:
- The failure reproduces in a fresh browser context
- The same problem appears with a clean account and cleared storage
- Network traces show the server returning the wrong data
- The UI fails even after all cache layers are removed
- The failure survives across browsers and across independent runs
Examples include broken API responses, incorrect feature flag logic on the server, selector changes after a UI refactor, and timing bugs caused by async rendering. State leakage can still be present, but it is not the primary cause.
Prevention patterns that reduce browser-state flakiness
Keep tests isolated by design
- Prefer a fresh context per test or per logical scenario
- Avoid sharing a browser profile unless the test specifically validates persistence
- Use independent test accounts or reset fixtures between runs
Be explicit about state setup
Do not rely on one test to prepare state for another. Each test should create, observe, and clean up what it needs.
Treat authentication as test infrastructure
Auth setup is often the first place state leaks appear. If you pre-seed login state, make it disposable and versioned, not a permanent profile file that quietly accumulates storage.
Add state diagnostics to failed runs
When a browser test fails, capture:
- Cookies
- localStorage keys
- IndexedDB presence
- service worker registration status
- loaded asset version or build ID
These artifacts turn a vague flaky failure into a reproducible diagnosis.
Not the right fix if you are hiding a real product defect
A clean-browser workaround should not become an excuse to ignore a real bug. If the app depends on stale storage to function, or if logout does not truly clear sensitive state, that is a product issue. The test may be revealing it.
The distinction is simple:
- State leak in the test means the test setup is wrong.
- Persistent bad state in the app means the application behavior is wrong.
Both need fixes, but they live in different layers.
Practical conclusion
If a browser test fails, start by asking whether the failure survives a clean browser context. If it does not, you are probably looking at leaked browser state rather than a regression. If it does, inspect the application, network responses, and test data with the same discipline.
For teams maintaining browser automation in CI, the best long-term habit is to make isolation visible. Clear the state you depend on, snapshot the state you inherit, and keep persistent profile reuse out of ordinary functional tests unless persistence is the thing you are validating.
FAQ
How do I know if a flaky test is caused by cookies or app code?
Run the test in a fresh browser context. If it passes there but fails in a reused profile, cookies or another persistent layer are likely involved.
Should I clear localStorage before every test?
Usually no, not as a band-aid. Prefer isolated browser contexts so localStorage starts empty by design.
Can service workers cause browser test failures?
Yes. They can serve stale assets or cached responses, especially after deployments or when tests reuse a profile.
Why does my test fail only after another test runs first?
That usually means one test is leaving behind cookies, storage, cache, or a service worker that the next test inherits.
Is a clean browser enough to prove the app is broken?
Not always, but it is a strong signal. Pair it with a clean account and network inspection before concluding that the application regressed.