Browser contexts explained: a practical Playwright guide
Test isolation is one of the hardest problems in end-to-end testing. When multiple tests run against the same browser instance and share cookies, local storage, or session state, you get flaky failures that are nearly impossible to debug. Browser contexts explained: a practical Playwright guide gives you the tool to eliminate this entire class of problems—each test runs in its own isolated session within the same browser, keeping your suite fast while keeping your tests reliable.
What is a browser context?
A browser context in Playwright is an isolated instance within a browser that behaves like an incognito window. It has its own cookies, local storage, session storage, and IndexedDB. Multiple contexts can exist within the same browser process, and they don't share state with each other or with the main browser context.
Think of it this way: a browser is a single Chrome or Firefox process. A context is a private browsing session within that process. When you create a new context, you get a clean slate—no cookies from previous tests, no cached authentication tokens, no leftover form data.
import { chromium } from '@playwright/test';
const browser = await chromium.launch();
const context1 = await browser.newContext();
const context2 = await browser.newContext();
// context1 and context2 are completely isolated
// Cookie set in context1 won't appear in context2
await browser.close();
This isolation happens at the browser engine level, not just in Playwright's API. When you navigate to the same URL in two different contexts, the server sees them as different sessions because they send different cookies and have different storage.
Each test gets a fresh context
The most common pattern in modern Playwright tests is to create a new context for each test case. Playwright's test framework makes this automatic through fixtures, but understanding what's happening under the hood helps you use contexts effectively.
When you write a Playwright test without explicitly managing contexts, the test function creates a new context for you. That context is destroyed at the end of the test. This means your login state, form submissions, and navigation history all disappear between tests.
import { test, expect } from '@playwright/test';
test('user can log in', async ({ page }) => {
await page.goto('https://app.example.com/login');
await page.fill('input[name="email"]', 'user@example.com');
await page.fill('input[name="password"]', 'correct-password');
await page.click('button[type="submit"]');
await expect(page).toHaveURL(/\/dashboard/);
});
test('invalid password is rejected', async ({ page }) => {
// This page is in a fresh context with no cookies
// Even though the previous test logged in successfully,
// this test starts with no session
await page.goto('https://app.example.com/login');
await page.fill('input[name="email"]', 'user@example.com');
await page.fill('input[name="password"]', 'wrong-password');
await page.click('button[type="submit"]');
await expect(page).toHaveText('Invalid credentials');
});
This automatic isolation prevents test pollution. If the first test leaves the user logged in, the second test isn't forced to handle that state. You don't need to write cleanup code between tests. Each test is independent.
In practice, this means your tests run faster overall because Playwright can run multiple tests in parallel. Three tests with fresh contexts in the same browser is faster than three sequential tests because the browser process itself isn't restarted.
Cookies and storage are isolated
The isolation goes deeper than just in-memory state. Each context has its own cookie jar, local storage, session storage, and IndexedDB database. These are completely separate from other contexts in the same browser.
This matters when you're testing authentication flows, user preferences, or any feature that depends on client-side state. If your application stores an auth token in a cookie, sets a theme preference in local storage, and caches data in IndexedDB, each context will have its own versions of all three.
import { chromium } from '@playwright/test';
const browser = await chromium.launch();
const context1 = await browser.newContext();
const page1 = await context1.newPage();
await page1.goto('https://app.example.com');
// Set auth token in context1
await page1.evaluate(() => {
localStorage.setItem('authToken', 'token-for-user-1');
document.cookie = 'sessionId=session-1';
});
const context2 = await browser.newContext();
const page2 = await context2.newPage();
await page2.goto('https://app.example.com');
// context2 has neither cookie nor storage from context1
const token = await page2.evaluate(() => localStorage.getItem('authToken'));
console.log(token); // null
const cookies = await context2.cookies();
console.log(cookies); // empty array (or only default cookies)
await browser.close();
You can also initialize a context with pre-set cookies or storage state. This is useful when you want to start a test in an authenticated state without going through the login flow every time.
import { chromium } from '@playwright/test';
const browser = await chromium.launch();
const context = await browser.newContext({
storageState: {
cookies: [
{
name: 'sessionId',
value: 'abc123',
domain: 'app.example.com',
path: '/',
expires: -1,
httpOnly: true,
secure: true,
sameSite: 'Lax',
},
],
origins: [
{
origin: 'https://app.example.com',
localStorage: [
{ name: 'authToken', value: 'token-xyz' },
{ name: 'theme', value: 'dark' },
],
},
],
},
});
const page = await context.newPage();
await page.goto('https://app.example.com/dashboard');
// The app sees the sessionId cookie and the stored auth token
// and treats the user as already logged in
await browser.close();
Isolation also prevents one test from accidentally modifying state that another test relies on. If test A changes a user's theme preference in local storage, test B won't see that change because they're in different contexts.
Fast to create
Creating a new context is a cheap operation. Unlike launching a new browser, which spawns a new process and loads the browser engine, creating a context reuses the existing browser and just sets up new storage areas. This is why Playwright can create a fresh context for every test without significantly hurting performance.
Benchmarks vary depending on your system, but creating a context typically takes 10–50 milliseconds. Creating a new browser takes 500–2000 milliseconds. When you run 100 tests, using contexts instead of browsers saves you several minutes.
import { test, expect } from '@playwright/test';
// This configuration creates one browser and one context per test
// It's much faster than creating a new browser per test
export const config = {
fullyParallel: true,
workers: 4,
};
// With 4 workers, Playwright can run 4 tests in parallel
// Each test gets its own context but they all share the browser process
// After 25 tests with this setup, you've used ~250ms for context creation
// If each test took a new browser, you'd have spent ~25 seconds
The speed of context creation is why you can afford to create a fresh context for every test without compromising suite performance. In fact, it's usually faster and safer to do so than to try to reuse contexts across tests.
Use multiple contexts for multi-user tests
One of the most powerful uses of contexts is testing multi-user flows. When you need to simulate two users interacting with the same application simultaneously—like a user sending a message while another user receives it—you can create multiple contexts and control them independently.
import { chromium } from '@playwright/test';
test('users can send and receive messages', async () => {
const browser = await chromium.launch();
// User 1's context
const user1Context = await browser.newContext();
const user1Page = await user1Context.newPage();
// User 2's context
const user2Context = await browser.newContext();
const user2Page = await user2Context.newPage();
// Both users log in
await user1Page.goto('https://chat.example.com/login');
await user1Page.fill('input[name="username"]', 'alice');
await user1Page.fill('input[name="password"]', 'password1');
await user1Page.click('button[type="submit"]');
await expect(user1Page).toHaveURL(/\/chat/);
await user2Page.goto('https://chat.example.com/login');
await user2Page.fill('input[name="username"]', 'bob');
await user2Page.fill('input[name="password"]', 'password2');
await user2Page.click('button[type="submit"]');
await expect(user2Page).toHaveURL(/\/chat/);
// User 1 sends a message
await user1Page.fill('textarea[name="message"]', 'Hello Bob!');
await user1Page.click('button:has-text("Send")');
await expect(user1Page).toHaveText('Message sent');
// User 2 sees the message appear
// (In a real app, this might require waiting for WebSocket updates)
await user2Page.waitForTimeout(500); // Wait for message to arrive
await expect(user2Page).toHaveText('Hello Bob!');
await browser.close();
});
Because each context has its own cookies and storage, they maintain separate login sessions. User 1 stays logged in as Alice even while User 2 is logged in as Bob. The server treats them as different sessions because they send different cookies.
This pattern is essential for testing collaborative features like real-time chat, shared documents, or multiplayer interactions. Without contexts, you'd have to spin up multiple browsers or navigate through complex workarounds to simulate multiple users.
Common mistakes
Sharing storage state between tests. It's tempting to log in once and reuse that storage state across multiple tests to save time. This couples your tests together. If one test modifies data that another test depends on, you get flaky failures. Use fresh contexts and accept the small overhead of repeated login flows.
Forgetting to close contexts. Each context holds resources. If you create contexts in a loop without closing them, you'll run out of memory. Always call context.close() or let the Playwright test framework manage context lifecycle for you.
Mixing context and browser context APIs. page.context() returns the context that owns a page, but you don't typically need it. Use the context object you created or received from the fixture directly.
Assuming contexts persist between test runs. Contexts are destroyed after each test in the standard Playwright test setup. If you need state to persist, you're fighting the framework. Reconsider your test design.
Key takeaways
- A browser context is an isolated session with separate cookies, storage, and IndexedDB within a single browser process.
- Creating a fresh context for each test eliminates state pollution and makes tests independent and parallel-safe.
- Context creation is fast (10–50ms) compared to browser creation (500–2000ms), so the performance cost of isolation is minimal.
- Use multiple contexts within the same test to simulate multi-user interactions and test collaborative features reliably.
- Contexts can be initialized with pre-set cookies and storage to start tests in a known state without repeating setup steps.
- Always close contexts explicitly or rely on Playwright's fixture cleanup to avoid resource leaks.






Comments
Loading comments…