How to Configure Multiple Browser Contexts in Playwright
A single browser process can host many fully isolated sessions at once, and the unit that delivers that isolation is the browser context. When a test logs in as an administrator in one tab and a read-only user in another, or when a scraper drives several authenticated accounts in parallel, the contexts are what keep their cookies, localStorage, and permission grants from bleeding into one another. This page shows how to launch a browser once, spawn several independent contexts from it, run them concurrently, and tear them down deterministically so long-running Node.js processes do not leak renderer memory. The patterns here are the practical application of Browser Contexts & Isolation, which sits under Playwright Setup & Core Architecture.
Root cause: pages share state until you give them separate contexts
A common mistake is opening several pages from a single context (or worse, expecting browser.newPage() to isolate them) and then being surprised when logging in on one page authenticates all of them. That is not a bug; it is the design. A context is the boundary that owns the cookie jar, the storage partitions, the permission grants, and the HTTP cache. Pages created from the same context share all of it. The moment you need two sessions that must not see each other's state — two users, an authenticated and a guest view, or several scraper identities — you need two contexts. Getting the launch-then-context ordering right, and closing contexts before the browser, is what turns "it worked on my machine" into a deterministic run on CI.
Choosing the isolation boundary: browser, context, or page
Three levels of separation exist, and picking the wrong one is the usual source of both flakiness and wasted CI minutes. A second browser launch starts another OS process with its own renderer pool, its own disk cache directory, and roughly a second of startup latency; it isolates everything but costs the most. A second context reuses that process while allocating a fresh cookie jar, storage partition, permission set, and cache, and it materializes in tens of milliseconds. A second page in the same context shares every one of those stores, which is exactly what you want when modelling one user opening two tabs and exactly what you do not want when modelling two users.
The practical rule: use one browser per engine, one context per identity or device profile, and one page per tab that identity would actually open. Separate browsers become justified only when you need process-level separation — a different proxy chain, a different launch flag, or a crash-isolation boundary for untrusted pages. Everything else belongs at the context level, including device emulation covered in Emulating Devices, Locales and Timezones.
Minimal reproducible example
The test below launches Chromium once, derives two isolated contexts from it, drives both pages concurrently, and closes each context before the browser. It is written against the @playwright/test runner so the example mirrors how you would actually ship it.
import { test, expect, chromium } from '@playwright/test';
test('two contexts stay isolated under concurrency', async () => {
// Launch ONE browser process; both contexts share its renderer.
const browser = await chromium.launch();
// Context A loads a previously saved authenticated session from disk.
const contextA = await browser.newContext({
storageState: 'auth-state.json', // cookies + localStorage for the admin user
viewport: { width: 1280, height: 720 },
});
// Context B is a clean guest session — no shared cookies with A.
const contextB = await browser.newContext({ locale: 'en-US' });
const pageA = await contextA.newPage();
const pageB = await contextB.newPage();
// Drive both sessions at the same time with Promise.all so the run is parallel.
await Promise.all([
pageA.goto('https://app.example.com/dashboard'),
pageB.goto('https://app.example.com/login'),
]);
// Each assertion sees only its own context's state.
await expect(pageA.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
await expect(pageB.getByRole('button', { name: 'Sign in' })).toBeVisible();
// Close contexts BEFORE the browser to flush state and free renderer memory.
await contextA.close();
await contextB.close();
await browser.close();
});
Step-by-step fix
- Launch the browser exactly once. Call
chromium.launch()(orfirefox/webkit) and reuse the returnedbrowserfor every context. Spawning a fresh browser per session multiplies process overhead for no isolation benefit, because contexts already provide it. - Create one context per identity. Call
browser.newContext()for each session you need. PassstorageState,viewport,locale, orpermissionshere so each context simulates a distinct user environment from the first navigation. - Open pages from the right context. Call
context.newPage()on the specific context — neverbrowser.newPage()when you need isolation, because that creates an implicit throwaway context you cannot configure. - Run concurrent work with
Promise.all(). Wrap the parallel navigations and actions inPromise.all()so the contexts genuinely run side by side instead of serially, and never share a mutable object between them. - Persist and reuse auth with
storageState. Serialize a logged-in session once viacontext.storageState({ path: 'auth-state.json' }), then feed that file intonewContext({ storageState })for every future context that needs the same identity, as detailed in Reusing Login State with storageState. - Close contexts before the browser. Call
context.close()for each context, thenbrowser.close()last. Wrap per-context work intry…finallyso cleanup runs even when an assertion throws.
The ordering in steps 1 and 6 is what makes the run deterministic: creation flows outward from the browser, and teardown flows back inward to it. The timeline below is the shape every well-behaved run has, whether it holds two contexts or two hundred.
Wiring contexts into the test runner as fixtures
Manual newContext() and close() calls are fine in a demonstration, but in a real suite they belong in fixtures so teardown cannot be skipped by an early failure. The runner's built-in browser fixture is worker-scoped — one launched process serves every test that worker executes — so building test-scoped context fixtures on top of it gives you per-test isolation without per-test launch cost. The use() call marks the boundary: everything before it is setup, everything after is teardown that runs even when the body throws.
import { test as base, expect, type BrowserContext } from '@playwright/test';
// Declare the two extra fixtures this suite hands to every test that asks for them.
type RoleFixtures = {
adminContext: BrowserContext;
guestContext: BrowserContext;
};
export const test = base.extend<RoleFixtures>({
// `browser` is worker-scoped, so this reuses one process across the whole file.
adminContext: async ({ browser }, use) => {
const context = await browser.newContext({ storageState: 'auth/admin.json' });
await use(context); // the test body runs here with a ready admin session
await context.close(); // teardown: always runs, even after a failed assertion
},
guestContext: async ({ browser }, use) => {
const context = await browser.newContext(); // no storageState = clean session
await use(context);
await context.close();
},
});
test('an admin sees an audit log the guest cannot', async ({ adminContext, guestContext }) => {
const adminPage = await adminContext.newPage();
const guestPage = await guestContext.newPage();
// Both identities hit the same URL concurrently from separate cookie jars.
await Promise.all([
adminPage.goto('https://app.example.com/audit'),
guestPage.goto('https://app.example.com/audit'),
]);
await expect(adminPage.getByRole('table', { name: 'Audit log' })).toBeVisible();
await expect(guestPage.getByRole('heading', { name: 'Sign in' })).toBeVisible();
});
Scoping the expensive half of this setup per worker rather than per test is covered in Worker-Scoped Fixtures for Expensive Setup, and the broader fixture model lives in Playwright Config & Fixtures.
Troubleshooting variants
Target closed or Browser has been closed errors
This almost always means the browser was closed while a context or page still had pending work, or cleanup ran in the wrong order. Close every context first, then the browser, and ensure each close() is awaited. If a context creation rejects, the unhandled rejection can tear down the browser early — guard each context's lifecycle in its own try…finally so one failure does not collapse the others.
Memory climbs across a long-running suite
Contexts that are never closed keep their renderer state alive, and in a long Node.js process that compounds into a leak. Confirm every newContext() has a matching close() by tracking creations and closures, and prefer a fixture that closes the context in teardown over manual cleanup. If you create dozens of contexts in a loop, close each one before opening the next rather than holding them all open. Scraper-side batching for the same problem appears in Running Parallel Scrapers with Worker Pools.
State still leaks between two contexts
If logging in to one context appears to authenticate the other, you are probably reusing a single context for both pages, or both contexts load the same storageState file. Verify each session has its own newContext() call, and give guest sessions no storageState at all. For per-worker parallelism in the test runner, pair this with worker-scoped setup described in Setting Up Global Fixtures for Parallel Tests.
A storageState file loads but the session is already expired
storageState is a snapshot, not a live session: if the token inside it expired between the setup run and the test run, every context built from it starts logged out and the failure looks like leakage rather than expiry. Regenerate the file in a setup project on each CI run instead of committing it, keep one file per role so a refresh for one identity cannot invalidate another, and assert on a logged-in element immediately after the first navigation so an expired snapshot fails loudly. Role-splitting patterns are in Testing Multiple User Roles in Parallel.
Verification
Confirm isolation three ways. First, assert that an action in context A leaves context B unchanged — for example, set a cookie in A and assert contextB.cookies() does not contain it. Second, run the spec under repetition (npx playwright test --repeat-each=10) and watch for stable results; flakiness here points to shared state or missing awaits. Third, capture a trace with --trace on and review each context's network and storage activity separately in the Playwright Trace Viewer to prove no cookie crossed a context boundary.
Add one machine check on top of the three manual ones. Log the process memory after every tenth context with process.memoryUsage().rss and fail the run if the figure trends upward across an otherwise identical loop — a flat line proves teardown is reclaiming what setup allocated. On CI, run the same spec with --workers=1 and with the full worker count and compare results; a suite that only passes single-threaded is sharing state that the context boundary was supposed to contain, and the sharded pipeline described in Running Playwright Tests in GitHub Actions with Sharding will surface it on every push rather than intermittently.
Frequently Asked Questions
What is the difference between a browser context and a separate browser instance?
A separate browser launch starts a new OS process with its own memory footprint, while a context is a lightweight isolated session inside an already-running browser. Contexts give you the same cookie and storage separation as separate browsers at a fraction of the resource cost, so prefer multiple contexts over multiple launches.
Can I share an authenticated session across multiple contexts?
Yes. Save the session once with context.storageState({ path }) and pass that file to newContext({ storageState }) for each context that should be the same user. To keep two users isolated, give each its own storageState file or none at all.
Why must I close contexts before closing the browser?
Closing the browser first can abort pending context work and surface Target closed errors, and contexts left open keep renderer state alive and leak memory in long processes. Always close each context, then close the browser last, awaiting every call.
How many contexts can one browser hold at the same time?
There is no hard limit in Playwright; the ceiling is the memory available to the machine, because each context that has an open page carries renderer allocations. On a typical CI runner a handful of concurrently active contexts per worker is comfortable, while dozens of idle-but-open contexts is the point where the run starts swapping. Create contexts lazily, close them as soon as their work finishes, and scale breadth with more workers rather than more open contexts inside one.