📖 The Story: The Token That Wouldn’t Set

A QA engineer needed to seed an auth token into localStorage before navigating to a dashboard. The code looked simple: await page.evaluate((t) => localStorage.setItem('token', t), 'abc123'). It worked — most of the time.

But every few runs, the test failed. The dashboard redirected back to login because the token wasn’t there. The engineer added a await page.waitForTimeout(100) after the evaluate, which fixed it most of the time, but occasionally the test still failed.

The root cause: page.evaluate runs in the page context, but the page hadn’t finished loading when the evaluate fired. The localStorage call silently failed because the page’s origin wasn’t established yet. The engineer needed to add a waitForLoadState before the evaluate — a non-obvious dependency that broke every new test.

The WebStorage API in 1.61 makes this a non-issue. page.localStorage.setItem() waits for the page to be ready, then sets the item. No timing hacks.

🎯 Why It Matters

Sync-Looking

page.localStorage.setItem('token', 'abc') — clean, readable, no stringified functions.

🔧

Typed

Full TypeScript support. getItem returns string | null, items() returns a record.

Auto-Waiting

Waits for the page’s origin to be ready. No more waitForTimeout hacks.

💾

Full Surface

getItem, setItem, removeItem, clear, items() — the same API you know from the browser.

🌍 Real World Example

A team seeds 12 localStorage keys before every test (theme, language, feature flags, A/B variant, auth token, etc.). Before 1.61, each key required a separate page.evaluate call with a stringified function, and the test setup was 24 lines of brittle boilerplate. With page.localStorage.setItem(), it’s 12 clean lines, fully typed, and the auto-waiting means no test ever fails because localStorage was set before the origin was ready.

🧒 Explain Like I'm 10

Imagine you want to put a note inside a box. Before, you had to write a letter that said “please put this note in the box”, give the letter to a messenger, and hope the messenger found the box.

Now, you just walk up to the box and put the note in yourself. No messenger, no letter, no hoping.

🎓 Professional Explanation

Web Storage (localStorage and sessionStorage) is the browser’s key-value store scoped to a page’s origin. Setting storage from a Playwright test previously required page.evaluate with a stringified function — async, hard to type, and sensitive to page load timing.

Playwright 1.61 introduces page.localStorage and page.sessionStorage as first-class objects. They expose getItem(), setItem(), removeItem(), clear(), and items() — the same surface as the browser’s Storage interface, but called from your test with full TypeScript support and Playwright’s auto-waiting built in.

📊 The Flow

WebStorage API vs page.evaluate

1. Test calls page.localStorage.setItem('token', 'abc')
⬇️
2. Playwright waits for the page origin to be ready
⬇️
3. Executes localStorage.setItem() in the page context
⬇️
4. Returns a Promise — await it and continue

⚙️ The WebStorage API

The API lives on page.localStorage and page.sessionStorage. Both expose the same methods. All calls are async (return Promises) but look synchronous — you await them like any other Playwright action.

typescript
import { test } from class="tk-str">'@playwright/test';

test(class="tk-str">'seed auth token before navigation', async ({ page }) => {
  await page.goto(class="tk-str">'https:class="tk-com">//app.example.com');
  await page.localStorage.setItem(class="tk-str">'token', class="tk-str">'abc123');
  await page.localStorage.setItem(class="tk-str">'theme', class="tk-str">'dark');
  await page.localStorage.setItem(class="tk-str">'featureFlag_newUI', class="tk-str">'true');

  await page.goto(class="tk-str">'https:class="tk-com">//app.example.com/dashboard');
  class=class="tk-str">"tk-com">// Dashboard reads token from localStorage — no login redirect
});

setItem(key, value) sets a single key. Playwright auto-waits for the page origin to be ready, so you don’t need waitForLoadState before the call.

typescript
class=class="tk-str">"tk-com">// Read values back
test(class="tk-str">'read localStorage', async ({ page }) => {
  await page.goto(class="tk-str">'https:class="tk-com">//app.example.com');
  await page.localStorage.setItem(class="tk-str">'token', class="tk-str">'abc123');

  const token = await page.localStorage.getItem(class="tk-str">'token');
  console.log(token);  class=class="tk-str">"tk-com">// class="tk-str">'abc123'

  const missing = await page.localStorage.getItem(class="tk-str">'nonexistent');
  console.log(missing);  class=class="tk-str">"tk-com">// null

  class=class="tk-str">"tk-com">// Get all key-value pairs as a record
  const all = await page.localStorage.items();
  console.log(all);  class=class="tk-str">"tk-com">// { token: class="tk-str">'abc123', theme: class="tk-str">'dark', ... }
});

getItem(key) returns string | null. items() returns a Record<string, string> of all key-value pairs — useful for snapshotting the full storage state.

typescript
class=class="tk-str">"tk-com">// Remove and clear
test(class="tk-str">'cleanup storage between tests', async ({ page }) => {
  await page.goto(class="tk-str">'https:class="tk-com">//app.example.com');

  class=class="tk-str">"tk-com">// Remove a single key
  await page.localStorage.removeItem(class="tk-str">'token');

  class=class="tk-str">"tk-com">// Clear all localStorage for the current origin
  await page.localStorage.clear();

  class=class="tk-str">"tk-com">// sessionStorage has the same API
  await page.sessionStorage.setItem(class="tk-str">'cartId', class="tk-str">'cart-456');
  const cartId = await page.sessionStorage.getItem(class="tk-str">'cartId');
  await page.sessionStorage.clear();
});

removeItem(key) deletes one key. clear() wipes all storage for the current origin. sessionStorage has the identical API but is scoped to the tab’s session.

🔄 Before vs After

Before: page.evaluate with stringified function (1.60 and earlier)

typescript
class=class="tk-str">"tk-com">// Async, brittle, sensitive to page load timing
await page.evaluate((token) => {
  window.localStorage.setItem(class="tk-str">'token', token);
  window.localStorage.setItem(class="tk-str">'theme', class="tk-str">'dark');
}, class="tk-str">'abc123');
class=class="tk-str">"tk-com">// Sometimes fails silently if the page origin isn't ready yet

The evaluate runs in the page context. If the page hasn’t finished loading, localStorage.setItem may silently fail because the origin isn’t established. Engineers add waitForTimeout hacks to work around it.

After: page.localStorage (1.61+)

typescript
import { test } from class="tk-str">'@playwright/test';

test(class="tk-str">'seed auth token', async ({ page }) => {
  await page.goto(class="tk-str">'https:class="tk-com">//app.example.com');
  await page.localStorage.setItem(class="tk-str">'token', class="tk-str">'abc123');
  await page.localStorage.setItem(class="tk-str">'theme', class="tk-str">'dark');

  await page.goto(class="tk-str">'https:class="tk-com">//app.example.com/dashboard');
  class=class="tk-str">"tk-com">// Dashboard reads token — no redirect
});

Clean, typed, auto-waiting. No stringified functions, no timing hacks. The same API surface as the browser’s localStorage, but called from your test.

⚠️ Gotcha

Storage is scoped to the page’s current origin. If you navigate to a different origin, you get a fresh storage scope — the keys you set on app.example.com are not visible on api.example.com. If you need to seed storage for multiple origins, set it after each goto that changes the origin.

⚠️ Common Mistakes

Mistake 1: Setting storage before goto

If you call page.localStorage.setItem() before page.goto(), there’s no page yet — the call will fail or set storage on about:blank. Always navigate to the origin first, then set storage.

Mistake 2: Forgetting storage is origin-scoped

localStorage is scoped to the origin. Setting it on app.example.com doesn’t affect api.example.com. If your test navigates across origins, set storage for each origin separately.

Mistake 3: Treating items() as synchronous

page.localStorage.items() returns a Promise. You must await it. Writing const items = page.localStorage.items() without await gives you a Promise object, not the storage record.

✅ Best Practices

  1. Always goto first — the page must be on the target origin before you set storage.
  2. Use items() for snapshots — useful for asserting the full storage state in one call.
  3. Clear storage in afterEach to prevent leakage between tests.
  4. Pair with storageState for auth — seed once, save, load into future contexts.
  5. Prefer setItem over evaluate — it’s typed, auto-waiting, and easier to read.

🏗️ Senior Engineer Deep Dive

When to use WebStorage vs storageState

page.localStorage.setItem() sets storage on the current page — useful mid-test when you need to inject state after navigation. storageState saves the entire storage (plus cookies) to a file that can be loaded into a fresh context — useful for auth setup tests. Use WebStorage for in-test mutations, use storageState for cross-test persistence.

Why auto-waiting matters here

The browser’s localStorage is only available after the page’s origin is established. With page.evaluate, you had to manually waitForLoadState or risk a silent failure. The WebStorage API handles this internally — it waits for the origin to be ready before executing the storage call. This eliminates an entire class of flaky tests.

💼 Interview Questions

Beginner
What is page.localStorage in Playwright 1.61?
Show Answer
A first-class WebStorage API on the Page object. It exposes getItem, setItem, removeItem, clear, and items() — the same surface as the browser's localStorage, but called from your test with full TypeScript support and auto-waiting.
Intermediate
How is page.localStorage different from page.evaluate(() => localStorage.setItem(...))?
Show Answer
page.localStorage is typed, auto-waiting (waits for the page origin to be ready), and doesn't require stringifying functions. page.evaluate is async, untyped, and sensitive to page load timing — localStorage.setItem may silently fail if the origin isn't established.
Advanced
When should you use WebStorage vs storageState?
Show Answer
Use page.localStorage for in-test mutations (setting a key mid-test). Use storageState for cross-test persistence (save the full storage + cookies to a file, load it into a fresh context for auth setup). WebStorage is for runtime state changes; storageState is for session bootstrapping.
Scenario
Your test sets localStorage via page.evaluate but sometimes fails silently. How do you fix it?
Show Answer
Switch to page.localStorage.setItem(). The WebStorage API auto-waits for the page origin to be ready, eliminating the timing issue. If you must stay on page.evaluate, add await page.waitForLoadState('domcontentloaded') before the evaluate — but the WebStorage API is the cleaner fix.

🏋️ Practical Exercise

🎯 Hands-On Practice easy

Seed Storage the Clean Way

  1. Write a test that navigates to https://example.com
  2. Set three localStorage keys using page.localStorage.setItem()
  3. Read them back with page.localStorage.items() and log the result
  4. Clear storage with page.localStorage.clear()
  5. Verify items() returns an empty object

🚀 Mini Project

🏗️ The Auth Seed Pattern

  1. Write a setup test that logs in via the UI and saves storageState to auth.json
  2. In your main tests, configure use: { storageState: 'auth.json' }
  3. Write a test that needs to override the theme mid-test — use page.localStorage.setItem('theme', 'dark')
  4. Assert the UI re-renders with the dark theme
  5. Compare the readability to the old page.evaluate approach

📝 Quiz

Test your understanding. Click an option to check your answer.

1. What methods does page.localStorage expose?
A) get, set, delete
B) getItem, setItem, removeItem, clear, items
C) read, write, clear
D) fetch, store, wipe
Answer: B — page.localStorage mirrors the browser's Storage interface: getItem, setItem, removeItem, clear, and items() (which returns all key-value pairs).
2. Do you need to await page.localStorage.setItem()?
A) No, it's synchronous
B) Yes, it returns a Promise
C) Only on Chromium
D) Only if the value is large
Answer: B — All WebStorage methods are async and return Promises. You must await them.
3. What does page.localStorage.items() return?
A) An array of keys
B) A Record of all key-value pairs
C) A single string
D) A Promise
Answer: B — items() returns a Promise> containing all key-value pairs in storage.
4. Is localStorage scoped to the page or the origin?
A) The page (per tab)
B) The origin (shared across tabs on the same origin)
C) The browser (shared everywhere)
D) The session
Answer: B — localStorage is scoped to the origin. All pages on the same origin share the same localStorage. sessionStorage is scoped to the tab.
5. What happens if you call setItem before page.goto()?
A) It works fine
B) It fails or sets storage on about:blank
C) It throws immediately
D) It waits indefinitely
Answer: B — There's no page yet, so the call either fails or sets storage on about:blank. Always navigate to the origin first.
6. Which Playwright version introduced the WebStorage API?
A) 1.59
B) 1.60
C) 1.61
D) 1.62
Answer: C — page.localStorage and page.sessionStorage shipped in Playwright 1.61 (June 2026).
7. What is the main advantage over page.evaluate?
A) It's faster
B) It's typed, auto-waiting, and doesn't require stringified functions
C) It bypasses CORS
D) It works offline
Answer: B — page.localStorage is fully typed, auto-waits for the page origin, and uses a clean API surface instead of stringified functions passed to evaluate.
8. Does sessionStorage have the same API as localStorage?
A) No, sessionStorage has fewer methods
B) Yes, identical API (getItem, setItem, etc.)
C) Only on Chromium
D) sessionStorage is synchronous
Answer: B — Both expose the same Storage interface. The difference is scope: localStorage persists across sessions, sessionStorage is per-tab.
9. What does getItem return for a missing key?
A) undefined
B) null
C) An empty string
D) It throws
Answer: B — getItem returns null for missing keys, matching the browser's Storage spec. TypeScript types this as string | null.
10. How do you wipe all localStorage for the current origin?
A) page.localStorage.deleteAll()
B) page.localStorage.clear()
C) page.localStorage.wipe()
D) page.evaluate(() => localStorage = {})
Answer: B — clear() removes all keys for the current origin. Useful in afterEach to prevent test leakage.

❓ FAQ

Q: Can I use WebStorage to set cookies?

A: No. Cookies and Web Storage are different APIs. Use context.addCookies() for cookies, page.localStorage / page.sessionStorage for Web Storage. The storageState API saves and restores both.

Q: Does WebStorage work with IndexedDB?

A: No. IndexedDB is a separate, more complex async database. Playwright does not have a first-class IndexedDB API — you still use page.evaluate for IndexedDB operations.

Q: Can I set storage on a popup or iframe?

A: Yes, but you need a reference to the page/frame. page.localStorage sets storage on the main frame’s origin. For a popup, use popup.localStorage. For an iframe, use frame.evaluate() until a first-class frame.localStorage API ships.

📦 Summary

🎯 Key Takeaways

  • page.localStorage / sessionStorage expose a typed, auto-waiting WebStorage API.
  • Always goto first — the page must be on the target origin before setting storage.
  • items() returns all key-value pairs as a Record—useful for snapshotting.
  • Storage is origin-scoped — setting it on one origin doesn’t affect another.
  • Use storageState for cross-test persistence, WebStorage for in-test mutations.