Root cause #
Web storage is scoped to an origin, not to a test. A browser context that survives from one test to the next carries every key the application wrote, and modern front ends read most of that state exactly once, during bootstrap: the auth token, the locale, the feature-flag payload, the onboarding-complete marker. By the time the test body executes localStorage.clear(), the store has already been dispatched into application memory and re-rendered the UI. The clear succeeds and changes nothing observable, so the test looks isolated while remaining coupled.
The second half of the mechanism is that storage APIs are origin-scoped, so they are unavailable on about:blank. A test that tries to clear storage before its first navigation throws a SecurityError — which is why teams end up putting the clear after visit(), right where it no longer helps. The way out is to run the clearing code inside the page but ahead of page scripts: Playwright’s addInitScript and Cypress’s onBeforeLoad hook both execute in that window. Cookies are different again: they live in the context’s cookie jar rather than the document, so they can be cleared at any time through the context API.
Step-by-step fix #
1. Let Playwright’s per-test context do the work #
Playwright creates a fresh browser context per test by default, which means empty cookies and empty storage without any hook at all. Leakage appears only when you opt out — by reusing storageState, or by creating a context yourself in a worker-scoped fixture.
// playwright.config.ts
// Trade-off: a shared storageState skips the login for every test and saves
// minutes of CI time, but couples all tests to one account's server-side data.
export default defineConfig({
use: {
storageState: undefined, // explicit: no seeded session
},
projects: [
{ name: 'authed', use: { storageState: 'playwright/.auth/user.json' } },
{ name: 'anonymous', use: { storageState: undefined } },
],
});
Split the suite into an authenticated project and an anonymous one rather than clearing a seeded session inside individual tests. Tests that mutate account-level data should sign in as their own user, along the lines of the per-worker namespacing in Isolating Database State in Parallel Jest Workers.
2. Clear before bootstrap with an init script #
When you do need an empty store inside a context that has one, register the clear as an init script so it runs ahead of every page script, on every navigation in that test.
// Trade-off: addInitScript runs on every navigation in the test, including
// redirects — cheap, but it will also wipe state a multi-step flow just wrote.
test.beforeEach(async ({ context }) => {
await context.clearCookies();
await context.addInitScript(() => {
try {
window.localStorage.clear();
window.sessionStorage.clear();
} catch {
/* storage is unavailable on opaque origins; ignore */
}
});
});
For IndexedDB, delete the database by name in the same init script — indexedDB.deleteDatabase('app-cache') — because a stale object store survives a localStorage.clear() untouched.
3. Keep Cypress test isolation switched on #
Cypress 13 clears cookies, local storage, session storage and the page between tests whenever testIsolation is true, which is the default. The common regression is disabling it to speed up a long spec.
// cypress.config.js
// Trade-off: testIsolation:false makes a spec one continuous session and is
// much faster, but every test after the first inherits the previous state.
module.exports = defineConfig({
e2e: {
testIsolation: true, // keep this on
},
});
// When a login is the slow part, cache the session instead of the page state:
beforeEach(() => {
cy.session('standard-user', () => {
cy.visit('/login');
cy.get('[data-test=email]').type('[email protected]');
cy.get('[data-test=password]').type('correct-horse');
cy.get('[data-test=submit]').click();
cy.url().should('include', '/dashboard');
});
});
cy.session() restores cookies and storage from a cached snapshot rather than replaying the UI flow, so you keep isolation and still skip the login cost. When a single test needs an empty store before the app boots, use the navigation hook:
// Trade-off: onBeforeLoad runs per visit, so a test with several visits clears
// several times — correct, but it will discard state the flow intentionally set.
cy.visit('/dashboard', {
onBeforeLoad(win) {
win.localStorage.clear();
win.sessionStorage.clear();
},
});
4. Assert the clean slate, do not assume it #
Isolation bugs are cheap to catch and expensive to debug, so make the empty state an explicit precondition in the specs most likely to be polluted.
// Trade-off: one extra assertion per test adds noise, but it converts a
// confusing downstream failure into an immediate, self-describing one.
test('starts from an empty store', async ({ page }) => {
await page.goto('/');
const keys = await page.evaluate(() => Object.keys(window.localStorage));
expect(keys).toEqual([]);
});
5. Handle the offline surfaces most suites forget #
An application with a service worker answers requests from its own cache, so a test can pass against data the previous test seeded even though the network was mocked differently this time. Unregister the worker and empty the named caches in the same init script that clears storage.
// Trade-off: unregistering the worker on every test removes a real production
// code path from coverage — keep one spec that exercises the offline behaviour.
await context.addInitScript(() => {
navigator.serviceWorker?.getRegistrations?.().then((rs) => rs.forEach((r) => r.unregister()));
if (window.caches) {
caches.keys().then((keys) => keys.forEach((k) => caches.delete(k)));
}
indexedDB.deleteDatabase('app-cache');
});
The ordering trap here is the same one that makes teardown of a mock service worker subtle: an unregistration is asynchronous, and a navigation that starts before it settles can still be served by the outgoing worker. Await the clean state rather than firing the request and hoping.
6. Verify the isolation instead of trusting it #
Add one spec whose only job is to prove that the reset works, and run it in the same project as the rest. It fails loudly the day someone changes the configuration, which is far cheaper than discovering the regression through a week of intermittent auth failures.
// Trade-off: a dedicated isolation spec adds a few seconds per run and is the
// only test that fails for the right reason when testIsolation is switched off.
test('writes in one test are invisible to the next', async ({ page }) => {
await page.goto('/');
await page.evaluate(() => localStorage.setItem('leak-probe', 'written'));
});
test('starts clean', async ({ page }) => {
await page.goto('/');
const probe = await page.evaluate(() => localStorage.getItem('leak-probe'));
expect(probe).toBeNull(); // fails immediately if isolation regresses
});
Pitfalls #
- Clearing after
visit()orgoto(). The application already read the value. Mitigation: clear in an init script oronBeforeLoad, before navigation. - Forgetting cookies. A session cookie keeps the user signed in even with empty
localStorage. Mitigation: pairclearCookies()with every storage clear. - Ignoring IndexedDB and service-worker caches. Offline-capable apps answer from a cache the storage clear never touched. Mitigation: delete the database and the named caches too.
- Disabling
testIsolationto make a spec faster. Speed bought with coupling is repaid in flakiness. Mitigation: keep isolation on and cache the login withcy.session(). - Sharing one
storageStatefile across write-heavy tests. Two tests mutating the same account race each other on the server. Mitigation: give mutating tests their own account, or their own project.
Reliability targets #
| Metric | Target | Notes |
|---|---|---|
| Storage keys at test start | 0 (anonymous project) | Assert in the smoke spec |
| Cookie count at test start | 0 (anonymous project) | Cleared through the context API |
| Clear cost per test | < 5 ms | Init-script clear is in-page and near-instant |
| Login cost per test | < 200 ms | Achieved with cy.session() or storageState |
| Order-dependent auth failures | 0 per week | Tracked across shuffled seeds |
Frequently Asked Questions #
Q: Why does localStorage.clear() throw a SecurityError in my test?
A: You called it before the first navigation, while the browser was still on about:blank. Storage is origin-scoped, so there is no store to clear yet. Register the clear as an init script instead, which runs inside the page immediately before its own scripts.
Q: Does clearing storage also reset the service worker?
A: No. A registered service worker and its Cache Storage entries survive localStorage.clear() and will keep answering requests with data from an earlier test. Unregister the worker and delete the named caches when the app is offline-capable; the teardown ordering is the same problem described in Tearing Down MSW Service Workers Between Tests.
Q: How do I keep a login fast without sharing one signed-in state everywhere?
A: Separate the credential from the session. Create the account through an API call in a fixture — cheap, and unique per test — then exchange it for a session with cy.session() or a saved storageState keyed on that account. You pay one API round trip instead of a UI login, and no two tests share server-side data.
Q: The app reads a feature flag from storage at bootstrap. How do I set it before the app boots? A: With the same mechanism that clears storage: write the key inside an init script, which runs after the document exists but before page scripts. Clearing and seeding are the same operation at the same moment — clear everything, then write only the keys this test intends the app to see.
Q: Is cy.session() safe if two specs use the same session name?
A: Yes for the browser state — Cypress validates and restores the cached cookies and storage per spec. It is not safe for server-side data: both specs act as the same user, so a test that mutates that account can still break another. Give mutating specs a distinct session name and account.