Playwright gives each test a fresh browser context: an isolated, incognito-style profile with no cookies, no localStorage, no sessionStorage, and no cache. Nothing a previous test did can leak into the next one, which is why a Playwright suite can run in parallel without tests corrupting each other.
The flip side is that your app sees every test as a brand new visitor. Logged out, no saved preferences, consent banner showing, every onboarding tooltip active. Testing anything beyond the first-visit experience means putting state into the context deliberately, and that is what this chapter covers.
Cookies live on the context, not the page. Read them with context.cookies():
test('login sets a session cookie', async ({ page, context }) => {
await page.goto('/login');
// ... perform login ...
const cookies = await context.cookies();
const session = cookies.find((c) => c.name === 'session_id');
expect(session).toBeDefined();
expect(session?.httpOnly).toBe(true);
expect(session?.secure).toBe(true);
});This reaches cookies the page cannot. httpOnly cookies are invisible to JavaScript running in the page, but context.cookies() reads from the browser itself, so you can assert security attributes like httpOnly, secure, and sameSite directly.
Writing goes through context.addCookies(). Each cookie needs a name, a value, and a scope, either as a url or as an explicit domain and path pair:
await context.addCookies([
{
name: 'session_id',
value: 'test-session-token',
url: 'https://staging.example.com',
},
{
name: 'currency',
value: 'EUR',
domain: '.example.com',
path: '/',
},
]);
await page.goto('/pricing');The url form is the convenient one and derives the domain and path for you. The domain form is for when you need control, such as a leading dot to cover subdomains. Add cookies before the first goto, and the initial request already carries them, exactly as a returning visitor's browser would.
Web storage has no dedicated Playwright API for single values, and it does not need one. page.evaluate runs a function inside the page, where localStorage and sessionStorage are just there:
// read
const theme = await page.evaluate(() => localStorage.getItem('theme'));
expect(theme).toBe('dark');
// write
await page.evaluate(() => localStorage.setItem('onboarding_done', 'true'));
// sessionStorage works the same way
const draft = await page.evaluate(() => sessionStorage.getItem('draft'));One constraint shapes everything here: web storage is scoped per origin, and a page that has not navigated anywhere is on about:blank, which has no storage of your app. So you must goto the app first, then write, then usually reload so the app boots with the value present:
await page.goto('/');
await page.evaluate(() => localStorage.setItem('feature_new_editor', 'true'));
await page.reload();
await expect(page.getByRole('button', { name: 'New editor' })).toBeVisible();To have values in place before the app's first load, skip the reload dance with addInitScript, which runs in every new page before any of the app's own scripts:
await context.addInitScript(() => {
localStorage.setItem('feature_new_editor', 'true');
});
await page.goto('/');storageState bundles the context's cookies and localStorage into one JSON snapshot. Save it from a context in one script, and feed it to new contexts later:
// capture, typically at the end of a setup script
await context.storageState({ path: 'playwright/.auth/user.json' });// reuse, per test file
test.use({ storageState: 'playwright/.auth/user.json' });Every context created with that option starts with the saved cookies and localStorage already applied, before any page loads. This is the mechanism behind the login-once pattern from the authentication guide: the expensive flow runs one time, the snapshot makes every test start on the other side of it.
The snapshot is worth reading once with your own eyes. It is plain JSON with a cookies array and an origins array holding localStorage entries per origin, which makes it easy to check what your app actually persists, and to notice when a token you expected in a cookie is sitting in localStorage instead.
storageState deliberately skips sessionStorage, because browsers scope it per tab and drop it when the tab closes, so it has no place in a durable snapshot. If your app keeps auth tokens there, the login-once pattern appears to work and then every test starts logged out anyway.
The workaround documented on playwright.dev is a manual round trip. Serialize it after your setup flow:
// in the setup script, after logging in
const sessionData = await page.evaluate(() => JSON.stringify(sessionStorage));
fs.writeFileSync('playwright/.auth/session.json', sessionData, 'utf-8');Then re-inject it in new contexts with an init script, guarded by hostname so it only applies to your app:
const sessionData = JSON.parse(
fs.readFileSync('playwright/.auth/session.json', 'utf-8')
);
await context.addInitScript((storage) => {
if (window.location.hostname === 'staging.example.com') {
for (const [key, value] of Object.entries(storage)) {
window.sessionStorage.setItem(key, value as string);
}
}
}, sessionData);Once you can write cookies and storage, a whole category of slow test setup disappears. Anything your app persists client-side can be planted directly instead of reproduced through the UI.
Feature flags are the everyday case. Rather than clicking through an admin panel to enable a flag, seed it:
test('new dashboard behind the flag', async ({ context, page }) => {
await context.addInitScript(() => {
localStorage.setItem('flags', JSON.stringify({ newDashboard: true }));
});
await page.goto('/dashboard');
await expect(page.getByRole('heading', { name: 'Dashboard (beta)' })).toBeVisible();
});The same applies to dismissed banners, completed onboarding, saved locale, or A/B bucket assignments. One caveat keeps these tests honest: seeded state must match what the app really writes, key for key and format for format. When the app renames a storage key, a test seeding the old key quietly tests a state no user can reach anymore. Capturing a fresh storageState from a real flow now and then is a cheap way to check your seeds against reality.
Some scenarios need state to disappear partway through, the main one being session expiry. context.clearCookies() wipes cookies, and it accepts a filter to remove only one:
test('expired session redirects to login', async ({ page, context }) => {
await page.goto('/dashboard');
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
await context.clearCookies({ name: 'session_id' });
await page.getByRole('link', { name: 'Settings' }).click();
await page.waitForURL('**/login');
});Web storage clears through the page, same as it was written:
await page.evaluate(() => localStorage.clear());
await page.evaluate(() => sessionStorage.clear());Use mid-test clearing sparingly. Between tests it is never needed, because the fresh context already guarantees a clean slate, and a clearCookies() in a beforeEach is a sign the suite is fighting isolation it already has.
Consent banners tie the whole chapter together: a fresh context shows the banner, a click writes a cookie, and the cookie suppresses the banner afterwards. Each of those steps is assertable:
test('accepting consent sets the cookie and hides the banner', async ({
page,
context,
}) => {
await page.goto('/');
const banner = page.getByRole('region', { name: 'Cookie consent' });
await expect(banner).toBeVisible();
await banner.getByRole('button', { name: 'Accept all' }).click();
await expect(banner).toBeHidden();
const consent = (await context.cookies()).find((c) => c.name === 'cookie_consent');
expect(consent?.value).toBe('accepted');
await page.reload();
await expect(banner).toBeHidden();
});The reload at the end is the part suites tend to skip, and it is the assertion that catches real bugs: a banner that hides on click but reappears on every visit because the cookie never persisted. For all other tests, seed the consent cookie with addCookies before goto, so the banner never renders and never steals a click from the element under test.
Two mistakes account for most cookie trouble in Playwright suites. The first is writing cookies too late. addCookies after goto means the first request went out bare: the server rendered the logged-out or consentless version, and depending on caching, later requests may keep seeing it. Seed the context first, navigate second.
The second is domain mismatches. A cookie added for example.com does not apply on app.example.com, and a cookie whose domain does not match the URL your test visits is silently ignored, with no error to point at. When a seeded cookie seems to have no effect, print await context.cookies(page.url()), which returns only the cookies that apply to that URL, and compare against what you added. Watch the scheme too, since a secure cookie will not attach to a plain http://localhost app.
The same silent-mismatch logic applies to storage: localStorage seeded for one origin does nothing on another, which matters when tests hop between a marketing domain and an app subdomain.
State bugs like sessions that drop early or consent choices that do not stick tend to surface for real users before they surface in CI. Hyperping browser checks run Playwright scripts like these against production on a schedule, so a login that stops persisting or a banner that reappears triggers an alert instead of a support ticket.