Playwright architecture, selector reliability, and advanced interaction patterns.

Reusing Login State with storageState

A login form is the slowest, least interesting part of almost every end-to-end suite, and running it before each test multiplies a four-second round trip by the number of tests you own. Playwright's storageState serialises an authenticated browser context — its cookies and per-origin localStorage — into a JSON file that any later context can load at creation time, so the browser starts already signed in. This page covers how to capture that file correctly, how to wire it into a project graph so every worker gets it, and how to diagnose the failures that appear when the saved state no longer matches what the application expects.

Per-test login compared with a single saved state Three tests each repeating a login are contrasted with one setup login that writes a state file consumed by every test. Without storageState: every test pays the login cost login 4.2 s test login 4.2 s test login 4.2 s test 12.6 s of the run is repeated authentication With storageState: one login, reused by every test setup login 4.2 s user.json cookies + origins test test test runs once each test starts signed in
The saved state file turns authentication from a per-test cost into a one-off cost paid before the suite starts.

Root cause: a fresh context has no identity

Every Playwright test receives a brand-new browser context, which is an empty cookie jar, an empty localStorage and an empty cache. That isolation is the reason tests do not leak state into each other, but it also means the application has no way to recognise the browser, so it redirects to the login page. Repeating the login in beforeEach restores identity at the cost of running the slowest flow in the product on every test.

storageState breaks the dependency between "having an identity" and "performing a login". Playwright can serialise the two client-side stores a session actually lives in — cookies and localStorage, keyed per origin — and rehydrate them into a new context before the first navigation. The context is still isolated from every other context; it simply starts from a snapshot instead of from nothing. This page sits under Authentication & Session State within Playwright Setup & Core Architecture.

Minimal reproducible example

The pattern below is the one worth removing. It is correct and it passes, but the login runs once per test, and it re-runs on every retry as well.

import { test, expect } from '@playwright/test';

// Anti-pattern: the same login round trip executes before every single test.
test.beforeEach(async ({ page }) => {
  await page.goto('/login');
  await page.getByLabel('Email').fill('qa@example.com');
  await page.getByLabel('Password').fill(process.env.QA_PASSWORD!);
  await page.getByRole('button', { name: 'Sign in' }).click();
  // The server sets a session cookie; the SPA also writes a JWT to localStorage.
  await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
});

test('shows the invoice table', async ({ page }) => {
  await page.goto('/billing');
  await expect(page.getByRole('table', { name: 'Invoices' })).toBeVisible();
});

test('shows the seat count', async ({ page }) => {
  await page.goto('/team');
  await expect(page.getByText('3 of 10 seats used')).toBeVisible();
});

What the state file actually contains

context.storageState() returns — and with a path option writes — a JSON document with exactly two top-level arrays. cookies holds every cookie the context accumulated, each with name, value, domain, path, expires, httpOnly, secure and sameSite. Session cookies are recorded with expires: -1 and are restored as session cookies. origins holds one entry per origin the context visited that wrote to localStorage, each carrying that origin's key/value pairs.

Everything else is absent. sessionStorage is deliberately excluded because its lifetime is the tab, not the profile. Service worker registrations, caches and in-memory JavaScript variables are gone too. IndexedDB is excluded by default; recent Playwright versions accept context.storageState({ path, indexedDB: true }) when an application keeps its refresh token there. If your login flow ends with data in any of those stores, restoring cookies alone will not produce a signed-in app.

The file is plain JSON, so read it rather than guessing when something breaks. Two fields explain most misbehaviour: expires, which tells you whether the session has already lapsed, and domain, which tells you whether the browser will attach the cookie to the host your tests navigate to. Also note what the shape implies about scope. Because state is captured per origin, a product spread across app.example.com and admin.example.com needs the setup test to visit both before saving, otherwise the second origin starts anonymous. The same rule applies to a local run served from a port that differs from the one used at capture time.

Anatomy of a storageState JSON file The file holds a cookies array and an origins array with localStorage entries, while sessionStorage, IndexedDB and in-memory state are excluded. playwright/.auth/user.json cookies name, value, domain, path expires (-1 means session) httpOnly, secure, sameSite origins origin: https://app.example.com localStorage: name / value pairs one entry per origin visited Not captured sessionStorage IndexedDB (opt in) service worker caches in-memory variables HTTP cache entries Rebuild these with addInitScript(), or storageState with indexedDB set to true browser.newContext({ storageState })
Only cookies and per-origin localStorage survive the round trip to disk; anything else has to be reconstructed when the context boots.

Step-by-step fix

  1. Write a dedicated setup file that performs one real login. Create tests/auth.setup.ts, drive the actual form with getByLabel() and getByRole(), and assert on a post-login element before saving. Saving before the app has finished writing its token produces a file that looks valid and authenticates nothing.
  2. Persist the context with context.storageState({ path }). Call it on page.context() at the end of the setup test and write to playwright/.auth/user.json. The method resolves once the JSON has been flushed to disk, so no extra synchronisation is needed.
  3. Register the setup as a project and depend on it. Add a project matching /.*\.setup\.ts/ and give every browser project dependencies: ['setup'] plus use: { storageState: STORAGE_STATE }. Playwright then runs setup to completion before any dependent project starts, in a single place rather than per file — the same project graph described in Setting Up an Auth Setup Project.
  4. Add the auth directory to .gitignore. The file contains live session credentials. Ignore playwright/.auth/ and generate it fresh in CI/CD rather than caching it between pipeline runs, where it would outlive the session's server-side lifetime.
  5. Opt individual tests out of the saved identity. Login, signup and permission-denied tests need an anonymous browser. Call test.use({ storageState: { cookies: [], origins: [] } }) inside a describe block to override the project default for those tests only.
  6. Regenerate the state instead of trusting an old file. Treat the file as a build artefact of the current run. If a local run reuses a file older than the session TTL, delete it or guard the setup test so it re-authenticates when the cookie's expires timestamp has passed.
  7. Reuse the same state for API calls. request.newContext({ storageState }) accepts the identical file, so fixtures that seed data over HTTP authenticate as the same user as the UI, keeping the two halves of a test consistent.
  8. Split the file per role once permissions diverge. Write admin.json and viewer.json from separate setup tests and point different projects at each, which is the basis for testing multiple user roles in parallel.
// tests/auth.setup.ts
import { test as setup, expect } from '@playwright/test';

const STORAGE_STATE = 'playwright/.auth/user.json';

setup('authenticate as the standard user', async ({ page }) => {
  await page.goto('/login');
  await page.getByLabel('Email').fill(process.env.QA_EMAIL!);
  await page.getByLabel('Password').fill(process.env.QA_PASSWORD!);
  await page.getByRole('button', { name: 'Sign in' }).click();

  // Wait for a signal that the session is fully established. Saving before the
  // SPA has written its token yields a file with cookies but no localStorage.
  await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();

  // Serialise cookies + per-origin localStorage to disk.
  await page.context().storageState({ path: STORAGE_STATE });
});
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';

const STORAGE_STATE = 'playwright/.auth/user.json';

export default defineConfig({
  use: { baseURL: 'https://app.example.com' },
  projects: [
    // Runs first and produces the state file.
    { name: 'setup', testMatch: /.*\.setup\.ts/ },
    {
      name: 'chromium',
      use: { ...devices['Desktop Chrome'], storageState: STORAGE_STATE },
      dependencies: ['setup'], // guarantees the file exists before tests run
    },
    {
      name: 'webkit',
      use: { ...devices['Desktop Safari'], storageState: STORAGE_STATE },
      dependencies: ['setup'],
    },
  ],
});
One setup project feeding every parallel worker The setup project writes a state file that three parallel workers each load when creating their own isolated browser context. setup project auth.setup.ts user.json written once worker 1 storageState: user.json worker 2 storageState: user.json worker 3 storageState: user.json the file is read only at context creation, so workers never contend
Because each worker only reads the file when building its context, the same snapshot scales to any level of parallelism.

Troubleshooting variants

ENOENT: no such file or directory, open 'playwright/.auth/user.json'

The browser project started before anything produced the file. Almost always the dependencies: ['setup'] entry is missing, the testMatch pattern does not match the setup file's name, or the run was filtered — --project=chromium on its own still pulls in declared dependencies, but --grep filters can leave the setup test with nothing to execute. Verify the graph with npx playwright test --list, which prints the setup test above the dependent projects. A second cause is a relative path resolved from the wrong directory: storageState paths resolve against the current working directory, so prefer a path built from __dirname in monorepos.

Tests redirect to /login even though the file exists

The saved cookie is no longer accepted. Open the JSON and read the expires field: a Unix timestamp in the past means the session outlived its TTL, which happens whenever a state file is cached between CI runs. Compare domain against the host in your baseURL too — a cookie written for localhost is not sent to 127.0.0.1, and a cookie scoped to app.example.com is not sent to a preview deployment on a different subdomain unless it was set with a parent domain. Finally, servers that rotate a CSRF token or bind the session to a fingerprint will reject a replayed cookie by design; those applications need a fresh login per run, or an API-issued token injected with context.addCookies().

The app loads but behaves as though the user is anonymous

Cookies restored, token missing. Check whether the login writes anything into sessionStorage or IndexedDB, neither of which is in the default snapshot. Capture the missing values in the setup test and replay them with addInitScript(), which runs before any page script on every navigation in the context, or enable the indexedDB: true option when saving. If the value is a short-lived access token the application refreshes on boot, restoring it is usually unnecessary — restoring the long-lived refresh cookie is enough.

import { test, expect } from '@playwright/test';

// Restore a sessionStorage payload that storageState does not carry.
test.beforeEach(async ({ context }) => {
  const session = JSON.parse(process.env.SESSION_SNAPSHOT ?? '{}');
  // addInitScript runs before any page script, on every navigation.
  await context.addInitScript((data: Record<string, string>) => {
    for (const [key, value] of Object.entries(data)) {
      window.sessionStorage.setItem(key, value);
    }
  }, session);
});

test('renders the workspace switcher for a signed-in user', async ({ page }) => {
  await page.goto('/dashboard');
  await expect(page.getByRole('button', { name: 'Switch workspace' })).toBeVisible();
});
Diagnosing a test that still lands on the login page A decision tree splitting an unexpected login redirect into a missing file, an expired cookie and a domain mismatch, each with its remedy. Test lands on /login with a saved state file File missing ENOENT on user.json Cookie expired expires is in the past Domain mismatch scoped to another host Declare the setup project as a dependency Regenerate per run and never cache it Align baseURL with the login host check the JSON before touching the test code
Three checks against the JSON file itself resolve nearly every unexpected redirect back to the login page.

Verification

Prove the state is doing real work rather than accidentally passing. First, delete playwright/.auth/user.json and run a single test with --project=chromium; the run should recreate the file through the setup dependency and still pass, which confirms the graph, not a stale artefact, is producing the identity. Second, inspect the file after a run: it should contain at least one cookie for your application's domain and, for a single-page app, a matching entry under origins. An origins array of length zero on a token-based app is the signature of a snapshot taken too early.

Third, add a negative test. Wrap a describe block in test.use({ storageState: { cookies: [], origins: [] } }), navigate to a protected route and assert that the login form appears. If that test also lands on the dashboard, some other mechanism — a shared server session, a default user in a seeded database — is authenticating your tests and the saved state is not being exercised at all. Finally, open a trace in the Playwright Trace Viewer and read the first request's Cookie header in the network tab; seeing the session cookie on the very first navigation is direct evidence the state loaded before page load. Suites that authenticate this way also lose a common source of intermittent failure, which is worth knowing when triaging flaky tests.

Frequently Asked Questions

Is a shared state file safe when tests run in parallel?

Yes, because each worker only reads it. Playwright loads the JSON when it creates a context and copies the values into that context's own cookie jar, so no two workers share mutable state and nothing writes back to disk during the run. Problems only appear if a test itself calls storageState({ path }) against the shared path mid-run, which introduces a genuine write race — save to a per-worker path if you need that. The trade-off is that all workers act as the same user, so tests that mutate account-level data still need separate accounts.

Should I commit the state file to version control?

No. It holds a live session cookie and often a bearer token, which is a credential with the same power as a password for as long as the session lasts. Add playwright/.auth/ to .gitignore and let the setup project regenerate it on every machine and every pipeline. Caching it between CI runs to save time backfires as soon as the server-side session expires, producing failures that look like application bugs.

How does storageState differ from a global setup script?

A globalSetup function runs once in Node before the test runner starts and has no fixtures, so you must launch a browser by hand and manage its lifetime yourself. A setup project is an ordinary test file: it gets the page fixture, appears in the HTML report, records a trace on failure, and honours retries. Both can produce the same JSON, but the project approach gives you the debugging surface of a normal test, which matters when the login flow breaks. Expensive non-auth setup can also live in worker-scoped fixtures.

Can I reuse the same state for an SSO or OAuth login?

Usually yes, since the identity provider's redirect ends with your own application setting a session cookie, and that cookie is what gets saved. The complication is that many providers block automated sign-in or require a second factor, so the setup test needs a test-only account or a bypass. The specifics are covered in handling OAuth and SSO redirects, and the same snapshot technique underpins scraping data behind login sessions.

Back to overview