Halmurat T.
Halmurat T.

Senior SDET

Home Blog Books ask About

The Dispatch

Weekly QA notes from the trenches.

Welcome aboard!

You're on the list. Expect real-world QA insights — no fluff, no spam.

© 2026 Halmurat T.

Automation 24
  • Selenium
  • Playwright
  • Appium
  • Cypress
AI Testing 5
CI/CD 6
  • GitHub Actions
  • Slack Reporting
QA Strategy 4
Case Studies 5
Blog/Automation
AutomationHalmurat T./March 10, 2026/10 min

Playwright's storageState() Is Contaminating Your Tests

Filed underplaywright/parallel-execution/framework-design/design-patterns
Playwright's storageState() Is Contaminating Your Tests

Table of Contents
  • What storageState() Actually Captures
  • How This Causes Cross-Contamination
  • Stage 1: Login writes more than auth tokens
  • Stage 2: storageState() captures all of it
  • Stage 3: Another context inherits the wrong state
  • Stage 4: Tests fail in confusing ways
  • The Fix: Strip localStorage Before Reuse
  • Why Not Just Clear localStorage in Each Test?
  • What About Selective Preservation?
  • Gotchas That Will Bite You in CI
  • Audit Your State Files Right Now

On this page

  • What storageState() Actually Captures
  • How This Causes Cross-Contamination
  • Stage 1: Login writes more than auth tokens
  • Stage 2: storageState() captures all of it
  • Stage 3: Another context inherits the wrong state
  • Stage 4: Tests fail in confusing ways
  • The Fix: Strip localStorage Before Reuse
  • Why Not Just Clear localStorage in Each Test?
  • What About Selective Preservation?
  • Gotchas That Will Bite You in CI
  • Audit Your State Files Right Now

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:

auth/worker-0.json
{
"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.

[ WARNING ]

storageState() captures cookies and localStorage in one blob. There’s no built-in parameter to save cookies only. If your app writes to localStorage during login — and most modern SPAs do — that state rides along into every test that reuses the file.

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 locale was 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.

src/test/java/utils/AuthStateManager.java
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:

src/test/java/fixtures/AuthFixture.java
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.

[ TIP ]

For TypeScript projects, the same approach works with JSON.parse and delete:

utils/strip-storage.ts
const state = JSON.parse(fs.readFileSync(stateFile, 'utf-8'));
delete state.origins;
fs.writeFileSync(stateFile, JSON.stringify(state, null, 2));

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:

src/test/java/utils/AuthStateManager.java
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);
}
usage
// Keep only the auth token, strip everything else
AuthStateManager.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:

terminal — list all localStorage keys from a state file
cat .auth/worker-0.json | python3 -c "
import json, sys
state = json.load(sys.stdin)
for origin in state.get('origins', []):
for entry in origin.get('localStorage', []):
print(entry['name'])
" | sort

If 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.

terminal — check for localStorage in state files
cat .auth/worker-0.json | python3 -c "
import json, sys
state = 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.

§ Frequently Asked FAQ
+ 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.

§ Further Reading 03 of 03
01Automation

Selenium's Alert Handling Crashed Our Parallel Suite

How UnhandledAlertException broke 8-thread parallel execution and why Playwright's event-driven dialog model avoids that entire failure pattern in practice.

Read →
02Automation

Shared Test Users Are Sabotaging Your Parallel Suite

How shared test accounts create race conditions in parallel Playwright runs, and the 3 isolation patterns that eliminated our 12% failure rate overnight.

Read →
03Automation

Migrating Off Cypress? Here's When to Keep It

An honest take on Cypress vs Playwright migrations from an SDET who's done three, including the signals that tell you not to migrate your suite just yet.

Read →

Don't miss a thing

Subscribe to get updates straight to your inbox.

HT

No spam · Unsubscribe anytime

Welcome aboard!

You're on the list. Expect real-world QA insights — no fluff, no spam.

§ Colophon

Halmurat T. — Senior SDET writing about test automation, CI/CD, and QA strategy from 10+ years in the enterprise trenches.

Set in
IBM Plex Sans, Lora, and IBM Plex Mono.
Built with
Astro, MDX, Tailwind CSS & Expressive Code. Served by Vercel.
Privacy
No cookies. No tracking scripts on the main thread — analytics run sandboxed via Partytown.
Source
github.com/Halmurat-Uyghur
Terminal
Try /ask to query Halmurat's notes in a shell prompt.

© 2026 Halmurat T. · Written in plain text, shipped in plain time.

Search
Esc

Search is not available in dev mode.

Run npm run build then npm run preview:local to test search locally.