Playwright's storageState() Is Contaminating Your Tests

Table of Contents
We had a Playwright suite running 6 parallel workers, each with its own test user and a storageState file to skip login. Tests passed in isolation. In parallel, one worker’s dashboard would load in French, another would show a collapsed sidebar that nobody collapsed, and a third would fail because a feature flag was already toggled. The auth was isolated — the state wasn’t.
The culprit was storageState() itself. It doesn’t save “auth.” It saves everything.
What storageState() Actually Captures
Most teams treat storageState() as “save my cookies so I can skip login.” That’s what the Playwright docs encourage — authenticate once, persist the state, reuse it. The problem is that storageState() is a full browser context dump, not an auth export.
Here’s what the saved JSON actually looks like:
{ "cookies": [ { "name": "session_id", "value": "abc123", "domain": ".app.com", "path": "/" } ], "origins": [ { "origin": "https://app.example.com", "localStorage": [ { "name": "sidebar_collapsed", "value": "true" }, { "name": "locale", "value": "fr-CA" }, { "name": "feature_flags", "value": "{\"newCheckout\":true}" }, { "name": "last_search_query", "value": "test order 42" } ] } ]}The cookies array is what you want — auth tokens, session IDs. The origins array is the problem. It contains every localStorage entry the app wrote during that login flow. And there’s no option to exclude it. The BrowserContext.storageState() API is all-or-nothing.
How This Causes Cross-Contamination
The contamination happens in stages, and it’s subtle enough that you won’t catch it until parallelism is high enough.
Stage 1: Login writes more than auth tokens
When Worker 0 logs in, the app stores the session cookie (good) and also writes UI preferences, feature flags, locale settings, and cached data to localStorage (invisible to your test). This is normal app behavior — your frontend developers aren’t doing anything wrong.
Stage 2: storageState() captures all of it
Your fixture calls context.storageState({ path: stateFile }) after login. It faithfully dumps cookies and the entire localStorage snapshot into the JSON file.
Stage 3: Another context inherits the wrong state
When you create a new browser context with browser.newContext({ storageState: stateFile }), Playwright restores everything — including the localStorage entries from the original login. Now your test starts with a browser that “remembers” things it never did.
Stage 4: Tests fail in confusing ways
The failures don’t look like auth issues. They look like UI bugs:
- An assertion fails because the sidebar is collapsed when the test expects it open
- A language mismatch because
localewas persisted from a different user’s session - A feature toggle is already flipped, skipping the setup step your test needs
- A search field is pre-populated with a query from a completely different test
If you’re running tests in parallel with isolated users, you’ve already solved the session collision problem. But if all workers save and restore storageState, they’re sharing localStorage snapshots across contexts — and that’s a different kind of contamination.
The Fix: Strip localStorage Before Reuse
The saved state file is plain JSON. The fix is to read it after saving, remove the origins array (which holds localStorage), and write it back. Only the cookies survive.
public class AuthStateManager {
/** * Strips localStorage from a Playwright storage state file, * keeping only cookies for auth reuse. */ public static void stripLocalStorage(Path stateFile) throws IOException { String json = Files.readString(stateFile); ObjectMapper mapper = new ObjectMapper(); ObjectNode root = (ObjectNode) mapper.readTree(json);
// Remove the "origins" array — this holds all localStorage entries root.remove("origins");
// Write back with only cookies mapper.writerWithDefaultPrettyPrinter() .writeValue(stateFile.toFile(), root); }}Call it immediately after saving the state:
BrowserContext context = browser.newContext();Page page = context.newPage();// ... perform login steps ...
Path stateFile = Path.of(".auth/worker-" + workerIndex + ".json");context.storageState(new BrowserContext.StorageStateOptions().setPath(stateFile));AuthStateManager.stripLocalStorage(stateFile);context.close();Now when another test loads this state file, it gets the auth cookies but starts with a clean localStorage. The app initializes its own defaults — sidebar expanded, correct locale, no stale feature flags.
Why Not Just Clear localStorage in Each Test?
You might think: “Why not call page.evaluate(() => localStorage.clear()) at the start of every test?” I’ve tried this. Three reasons it’s worse:
Timing. The app reads localStorage on initial page load — often before your test code runs. By the time you clear it, the app has already read the stale locale or feature_flags value and rendered accordingly. You’d need to clear it before navigating, which means creating the context, clearing storage, then navigating — and at that point, you’re fighting the framework instead of using it.
Fragility. You have to remember to add the clear step to every test. One missed test and the contamination is back. The strip-on-save approach is a single point of control — fix it once at the fixture level and every test benefits.
Incomplete. Some apps also use sessionStorage, which storageState() captures too. Clearing localStorage alone doesn’t cover it. Stripping the origins array removes both.
What About Selective Preservation?
Sometimes you want specific localStorage entries in your saved state — maybe your app stores the auth token in localStorage instead of a cookie (yes, this happens). In that case, strip selectively instead of removing the entire origins array:
public static void stripLocalStorageExcept(Path stateFile, Set<String> keepKeys) throws IOException { ObjectMapper mapper = new ObjectMapper(); ObjectNode root = (ObjectNode) mapper.readTree(Files.readString(stateFile)); ArrayNode origins = (ArrayNode) root.get("origins"); if (origins != null) { for (JsonNode origin : origins) { ArrayNode localStorage = (ArrayNode) origin.get("localStorage"); if (localStorage != null) { Iterator<JsonNode> it = localStorage.iterator(); while (it.hasNext()) { String name = it.next().get("name").asText(); if (!keepKeys.contains(name)) { it.remove(); } } } } } mapper.writerWithDefaultPrettyPrinter().writeValue(stateFile.toFile(), root);}// Keep only the auth token, strip everything elseAuthStateManager.stripLocalStorageExcept( stateFile, Set.of("auth_token", "refresh_token"));This is the approach I’d recommend for apps that store auth credentials in localStorage — which is more common in SPAs using JWTs than you’d expect. It’s something I discovered while debugging thread safety issues in parallel test execution at a large retailer where the React app used localStorage for everything, including the access token.
Gotchas That Will Bite You in CI
The strip approach is simple, but I’ve seen teams trip over a few edge cases that only surface in CI or at scale. These cost me real debugging hours, so save yourself the trouble.
State files regenerated on every run. If your CI pipeline runs the auth setup project before every suite execution — which it should — the strip step needs to happen every time too. I’ve seen teams add the strip logic locally, push clean state files, and then wonder why CI still flakes. The state files in .auth/ should be in your .gitignore and regenerated fresh per run. According to the Playwright best practices, the setup project is the canonical place for this.
Cookie expiry catches you off guard. Some apps set short-lived session cookies (15-30 minute TTL). If your suite takes longer than that, the stripped state file becomes useless mid-run — not because of localStorage contamination, but because the cookies themselves expired. At a telecom client, we had a 40-minute suite where the last 10 minutes of tests would all fail with 401s. The fix was adding a cookie refresh step in a global setup fixture that checked exp claims before reuse.
Third-party scripts pollute localStorage too. Your app might be disciplined about localStorage, but analytics SDKs, feature flag providers (LaunchDarkly, Split), and chat widgets write their own entries. One project I worked on had 23 localStorage entries after login — only 2 were from our app. The rest were from Segment, Intercom, and a consent management platform. The nuclear origins strip handles this cleanly, but if you’re using selective preservation, audit what third parties are writing:
cat .auth/worker-0.json | python3 -c "import json, sysstate = json.load(sys.stdin)for origin in state.get('origins', []): for entry in origin.get('localStorage', []): print(entry['name'])" | sortIf you see keys like _segment_, ld:*, or intercom-*, those are third-party entries that have no business in your test state.
Audit Your State Files Right Now
Here’s the one thing I’d recommend doing today: open one of your saved storageState JSON files and count the entries in the origins array. If you see more than zero localStorage entries, you have contamination potential.
cat .auth/worker-0.json | python3 -c "import json, sysstate = json.load(sys.stdin)for origin in state.get('origins', []): entries = origin.get('localStorage', []) print(f'{origin[\"origin\"]}: {len(entries)} localStorage entries') for e in entries: print(f' - {e[\"name\"]}')"If that outputs anything, your parallel tests are sharing state they shouldn’t be. Add the strip step to your auth fixture, and those mysterious “UI looks wrong but auth is fine” failures will disappear overnight.
Does storageState() also capture sessionStorage?
Yes. The origins array in the saved JSON includes both localStorage and sessionStorage entries. Stripping the entire origins array removes both, which is the safest default — each test context should start with a fresh session.
Why doesn't Playwright offer a cookies-only option for storageState()?
Playwright’s design philosophy treats storageState() as a full context snapshot for reliable replay. The assumption is that you want an exact reproduction of the browser state. For auth reuse specifically, this is too broad — but the JSON format is simple enough that post-processing is straightforward. There’s an open discussion on the Playwright repo about adding granular control.
Can I use this approach with Playwright's built-in auth setup project?
Absolutely. Playwright’s setup project pattern runs a setup script before tests. Just add the strip step after the storageState() call in your setup script. The state file will still be passed to dependent projects via storageState in the config — it’ll just contain only cookies. This pairs well with the worker-scoped user isolation patterns for full parallel safety.
