Playwright BrowserContext Guide for Isolation and Parallel Sessions
Use Playwright BrowserContext for clean test isolation, independent multi-user sessions, reusable auth state, context-wide controls, and safe parallel execution.

A Playwright BrowserContext is an isolated, non-persistent browser session. Use the built-in context and page fixtures for ordinary tests; Playwright creates a fresh context per test, isolating cookies, local storage, session storage, and other browser profile state. Create additional contexts from the browser fixture only when one scenario needs independent users or configurations. Contexts share a browser process but not session state, so they are the correct boundary for admin/member, buyer/seller, or sender/receiver flows. See the Playwright E2E complete guide for full project setup.
This guide answers the implementation questions that appear when a suite moves beyond one page: which object owns state, how to run two identities safely, when to reuse storageState, what parallel workers actually share, and why closing pages or clearing cookies is not equivalent to starting clean.
What BrowserContext Is and Is Not
The official isolation guide describes BrowserContexts as fast, isolated, incognito-like profiles. Playwright Test creates one context for each test and gives the test a default page inside it. With the library API, you create the same boundary manually through browser.newContext(), open pages, and close the context when finished.
Browser, BrowserContext, Page, and a test-runner worker solve different problems:
| Object | Owns or controls | Isolation implication | Typical lifecycle |
|---|---|---|---|
Browser | Browser engine process and contexts | Contexts in one browser are independent sessions | Usually one per worker |
BrowserContext | Cookies, origin storage, permissions, emulation, routes, pages | Security/session boundary between users | Fresh per Playwright Test test |
Page | One tab, frames, navigation, page-scoped events | Pages in one context are not independent users | One default page plus popups/tabs |
| Worker process | Test files, worker fixtures, browser instance | Separate OS process; cannot share in-memory variables with other workers | Reused until failure or run completion |
| Backend account/data | Server-side identity and records | Not isolated by BrowserContext | Must be partitioned by test or worker |
The BrowserContext API says normal contexts created with browser.newContext() are non-persistent and do not write browsing data to disk. This does not mean they have no state while open. Pages in the context share cookies and local storage for the same origin, popups remain in the parent page's context, and context-level routes or permissions apply across its pages.
Use the Built-In Fixtures by Default
Most tests should not call browser.newContext() at all:
import { test, expect } from '@playwright/test';
test('shows the account dashboard', async ({ page, context }) => {
// context is fresh for this test; page belongs to it.
await page.goto('/dashboard');
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
expect(page.context()).toBe(context);
});
The test runner handles creation and teardown. A second test receives a different context even if the worker process and browser are reused. That is the “start from scratch” strategy Playwright recommends over trying to clean every form of browser state between tests.
Do not put a BrowserContext in a module-level variable or a beforeAll merely to avoid login time. That changes unrelated tests into one ordered state machine. Save authenticated state once and initialize each fresh context from it instead. The official authentication guide shows setup-project and per-worker patterns for this purpose.
Create manual contexts when one test genuinely requires two simultaneous identities, different context options, or explicit library-style lifecycle control. Close every manual context in finally or a fixture teardown so a failed assertion does not leak pages, videos, routes, or downloads.
What State Crosses Pages and What Crosses Contexts
Use the ownership boundary to predict behavior:
| State or capability | Shared by pages in one context? | Shared by separate contexts? | Persistence tool |
|---|---|---|---|
| Cookies | Yes | No | storageState can save/restore them |
| localStorage for an origin | Yes | No | Included in storageState |
| sessionStorage | No, it is page-session scoped | No | Capture manually; not in storageState |
| Permissions and geolocation overrides | Context-level | No | Context options or methods, not storage state |
| Network routes | Context routes cover its pages | No | Re-register in each context/fixture |
| Pages and popups | Belong to the same context | No | Not persisted as browser state |
| Virtual WebAuthn passkeys | Context-scoped in 1.61 | No | Seed/install imperatively, not storage state |
| Server-side account records | External to the browser | Potentially yes | Provision unique data/accounts yourself |
The session-storage row is easy to misread. Two tabs in one signed-in context can share cookies and local storage yet have different session storage. Conversely, two contexts initialized from the same auth file have separate browser copies but can still mutate the same backend account.
For direct store manipulation in 1.61, see the localStorage and sessionStorage API guide. For passkeys, use the separate WebAuthn Credentials guide; neither state type changes the underlying context boundary.
Example 1: Run Two Independent Users in One Test
This complete TypeScript spec is runnable without an external server. Two contexts visit the same routed HTTPS origin. Each receives a different cookie and local-storage value, and each page renders only its own session. The same Browser instance owns both contexts.
// tests/multi-user-context.spec.ts
import { test, expect, type BrowserContext } from '@playwright/test';
const origin = 'https://sessions.example.test';
const html = `
<h1>Session</h1>
<output data-testid="cookie"></output>
<output data-testid="workspace"></output>
<script>
document.querySelector('[data-testid=cookie]').textContent = document.cookie;
document.querySelector('[data-testid=workspace]').textContent =
localStorage.getItem('workspace') || 'none';
</script>
`;
async function routeApp(context: BrowserContext) {
await context.route(origin + '/**', route =>
route.fulfill({ contentType: 'text/html', body: html }),
);
}
test('keeps admin and member sessions isolated', async ({ browser }) => {
const adminContext = await browser.newContext();
const memberContext = await browser.newContext();
try {
await routeApp(adminContext);
await routeApp(memberContext);
await adminContext.addCookies([{ name: 'role', value: 'admin', url: origin }]);
await memberContext.addCookies([{ name: 'role', value: 'member', url: origin }]);
const adminPage = await adminContext.newPage();
const memberPage = await memberContext.newPage();
await Promise.all([adminPage.goto(origin), memberPage.goto(origin)]);
await adminPage.localStorage.setItem('workspace', 'operations');
await memberPage.localStorage.setItem('workspace', 'quality');
await Promise.all([adminPage.reload(), memberPage.reload()]);
await expect(adminPage.getByTestId('cookie')).toContainText('role=admin');
await expect(adminPage.getByTestId('workspace')).toHaveText('operations');
await expect(memberPage.getByTestId('cookie')).toContainText('role=member');
await expect(memberPage.getByTestId('workspace')).toHaveText('quality');
} finally {
await adminContext.close();
await memberContext.close();
}
});
The two goto() and reload operations can proceed together because the sessions are independent. Promise.all coordinates known operations; it is not a substitute for test-runner parallelism and it does not make shared backend data safe.
In a real admin/member scenario, initialize each context with a role-specific storage-state file and assert identity before authorization behavior. Never log in as admin in one page and member in another page of the same context. The second login changes shared cookies and can silently turn both pages into the same user. The existing multiple-role auth-state guide shows a typed fixture for that pattern.
Example 2: Save State, Restore It, and Prove the Limits
The following offline-runnable spec captures a cookie and local-storage value from one context, initializes another context from the returned object, and proves that session storage was not restored.
// tests/context-storage-state.spec.ts
import { test, expect, type BrowserContext } from '@playwright/test';
const origin = 'https://state.example.test';
async function installPage(context: BrowserContext) {
await context.route(origin + '/**', route =>
route.fulfill({
contentType: 'text/html',
body: '<h1>State probe</h1>',
}),
);
}
test('restores cookie and local storage into a fresh context', async ({ browser }) => {
const source = await browser.newContext();
await installPage(source);
await source.addCookies([{ name: 'session', value: 'test-user', url: origin }]);
const sourcePage = await source.newPage();
await sourcePage.goto(origin);
await sourcePage.localStorage.setItem('theme', 'dark');
await sourcePage.sessionStorage.setItem('draft', 'not-persisted');
const state = await source.storageState();
await source.close();
const restored = await browser.newContext({ storageState: state });
try {
await installPage(restored);
const page = await restored.newPage();
await page.goto(origin);
expect(await restored.cookies(origin)).toEqual(
expect.arrayContaining([expect.objectContaining({ name: 'session', value: 'test-user' })]),
);
expect(await page.localStorage.getItem('theme')).toBe('dark');
expect(await page.sessionStorage.getItem('draft')).toBeNull();
} finally {
await restored.close();
}
});
The BrowserContext reference defines storage state as cookies, local storage, and an optional IndexedDB snapshot. The auth guide documents session storage separately. If Firebase or another login implementation stores tokens in IndexedDB, request it explicitly with storageState({ indexedDB: true }).
Treat a saved auth file as a credential. It can contain cookies and tokens that impersonate a test user. Keep it under playwright/.auth or the project's output directory, exclude it from version control, and avoid public report attachments. Delete or regenerate expired state rather than making every test tolerate anonymous redirects.
Context Options Belong at Context Creation
Context options are ideal for scenarios that need consistent browser-level conditions across all pages:
const context = await browser.newContext({
locale: 'en-GB',
timezoneId: 'Europe/London',
colorScheme: 'dark',
geolocation: { latitude: 51.5074, longitude: -0.1278 },
permissions: ['geolocation'],
serviceWorkers: 'block',
});
Set these through project use options when every test in a project needs them. Create an additional context only when one test compares configurations side by side. Contexts are cheap relative to separate browser launches, but unnecessary manual lifecycle still increases setup code and the chance of leaks.
Permission support can differ by browser and browser version; the API reference explicitly warns about that. Test the user-visible fallback as well as the granted path. Locale, timezone, and geolocation are independent controls: changing coordinates does not automatically change timezone or language.
Context routes apply to requests from all pages in the context, making them preferable for popups or multi-page flows. Playwright notes that route interception does not see requests already intercepted by a service worker and recommends blocking service workers when routing behavior is the goal. If service-worker behavior is itself under test, keep it enabled and use the dedicated service-worker APIs and limitations.
BrowserContext and Parallel Test Execution
Browser contexts provide test isolation; worker processes provide concurrency. The parallelism guide says test files run in parallel by default, tests within one file run in order by default, and each worker starts its own browser. Playwright reuses a worker where possible, but still creates a fresh context for every test using the standard fixtures.
Turning on fullyParallel allows individual tests in files to run across workers:
// playwright.config.ts
import { defineConfig } from '@playwright/test';
export default defineConfig({
fullyParallel: true,
workers: process.env.CI ? 4 : undefined,
});
Do this only after tests and test data are independent. Context isolation prevents browser-state leakage, but it cannot stop two workers from editing the same database row, draining the same queue, or revoking the same user's session. Use unique resources or worker-specific accounts. The official auth guide uses test.info().parallelIndex to select or create one account per parallel worker and saves each worker's state separately.
Do not create a global browser yourself inside test files to “improve parallelism.” The runner already manages browsers, contexts, retries, projects, traces, and teardown. Manual browser ownership bypasses those integrations and usually makes failures harder to attribute.
Choose the Smallest Context Pattern That Fits
Use one decision rule: keep the runner-owned context unless the scenario itself requires another independent browser session.
| Scenario | Context pattern |
|---|---|
| Ordinary one-user behavior | Built-in page and context fixtures |
| Same tests under several roles or browsers | Projects with role-specific storageState and device options |
| Two actors interacting in one scenario | Two manual contexts or typed context fixtures created from browser |
| Many parallel tests need reusable unique accounts | Worker fixture creates one auth file per parallelIndex; tests still get fresh contexts |
| Product explicitly depends on a disk profile | Deliberate persistent-context suite, separated from normal isolated tests |
This choice keeps reports and teardown aligned with Playwright Test. It also makes code review straightforward: every manual newContext() should correspond to a named actor or configuration that could not use the built-in context. If no such reason exists, remove it.
Context-Level Events, Routes, and Cleanup
Use context events when behavior can happen in any page. A context page event captures a popup regardless of which page opened it, and context request/response events observe traffic across pages. Page-level listeners are better when the scenario intentionally concerns one tab.
Close the context, not just its currently visible page, when the session is finished. context.close() closes every page in that context and finalizes context-owned artifacts. A useful reason can be supplied to interrupted operations in supported versions:
await context.close({ reason: 'multi-user scenario finished' });
Avoid closing the built-in context fixture manually; let the runner tear it down. Manual contexts created from browser are your responsibility. A fixture is often the cleanest owner because its code after await use(value) runs as teardown even when the test fails.
Failure Diagnosis by Ownership Boundary
Two pages unexpectedly become the same user
They are probably in one context. Pages share context cookies, so the most recent login changed both. Create one context per identity, initialize each with its own state, and assert an identity marker before testing collaboration or authorization.
A fresh context is still affected by another test
Determine whether the state is actually browser-side. Shared database records, cache entries, feature flags, email inboxes, and test accounts survive context creation. Partition or reset the external resource. If the leak is browser-side, look for a manually reused context, persistent profile, or state file shared unintentionally.
storageState loads but the app redirects to login
The cookie or token may have expired, the file may belong to another origin/environment, IndexedDB may have been omitted, or login completion may have been captured before redirects finished setting cookies. Regenerate state after an observable signed-in condition. Do not add retries around an invalid credential file.
sessionStorage is missing after state restoration
That is expected. Save selected session values separately and restore them before application scripts with an origin-restricted init script. The Web Storage guide includes a complete 1.61 capture/restore example.
Context routing misses a request
Check whether a service worker handled it, whether the URL pattern is correct, and whether the route was registered before navigation/request creation. Page routes take precedence when both match. For general network mocking, block service workers unless their behavior is the subject of the test.
target page, context, or browser has been closed
Find the lifecycle owner. A helper may close a context still used by another page, a finally block may run too early, or browser shutdown may interrupt contexts. Keep creation and closure in the same fixture/helper and avoid sharing manual contexts across concurrently running tests.
Parallel tests pass alone but fail together
Browser context isolation is working, but external data is colliding. Include a run ID and parallelIndex in accounts or resource names, make cleanup idempotent, and avoid tests that mutate shared account-level settings. Reducing workers can confirm the diagnosis but is not the final fix.
Version Scope and Limitations
BrowserContext isolation and the standard test fixtures long predate Playwright 1.61. Do not describe contexts, browser.newContext(), per-test isolation, parallel workers, or storageState as 1.61 additions. Relevant recent milestones in the current official docs are setStorageState() and isClosed() in 1.59, several context-wide page lifecycle events in 1.60, and browserContext.credentials in 1.61.
This guide targets non-persistent contexts and Playwright Test's normal runner lifecycle. Persistent profiles have different disk and single-profile constraints and should be used only when the product scenario genuinely requires a user data directory. BrowserContext isolation also does not emulate separate machines, IP addresses, browser processes, or backend tenants. Use projects, proxies, separate workers/machines, and isolated test infrastructure when those are the real boundaries.
Service-worker inspection is Chromium-specific in the current docs, and supported permissions vary. Storage-state files exclude session storage and passkeys; IndexedDB capture is optional. These are explicit limits, not reasons to abandon contexts.
For reliable actions inside each session, apply the Playwright locator best practices. The older browser-context isolation article offers additional fixture variations. Browse the QA skills directory or install the Playwright CLI skill for agent-assisted browser inspection, but keep context ownership, auth files, and parallel account allocation explicit in reviewed test code.
Frequently Asked Questions
Does Playwright create a new BrowserContext for every test?
Yes, when tests use the standard Playwright Test fixtures. Each test gets an isolated context and a default page. Manual library scripts must create and close contexts themselves.
Are two pages in one context isolated users?
No. They share cookies and origin-local storage, and a login in one can change the other's identity. Use separate contexts for separate users. Page-specific session storage does not turn pages into secure identity boundaries.
Is BrowserContext the same as an incognito window?
It is an incognito-like, isolated non-persistent profile. The analogy is useful for cookies and storage, but tests should rely on Playwright's documented API rather than browser UI assumptions.
Can multiple contexts run in parallel in one browser?
Yes. A single scenario can operate several independent contexts, and Playwright Test workers also run tests concurrently. Parallel safety still depends on isolating backend accounts and data, not just browser state.
Should I reuse one context to make tests faster?
Not across unrelated tests. The official isolation guidance favors a fresh context because cleanup is incomplete and easy to forget. Reuse authenticated storageState in fresh contexts instead of reusing the context itself.
What does BrowserContext storageState save?
It saves cookies and local storage, plus IndexedDB when explicitly requested. It does not persist session storage or the 1.61 virtual WebAuthn authenticator. Protect auth-state files as credentials.
When should I create a context from the browser fixture?
Create one when a single test needs another independent user, another set of context options, or explicit lifecycle control. Use the built-in context for ordinary one-user tests so the runner owns cleanup and artifacts.
Why do parallel tests still interfere when contexts are isolated?
They are usually sharing something outside the browser: an account, database record, feature flag, queue, mailbox, or environment. Allocate unique external state by test or worker and make cleanup deterministic.