Playwright architecture, selector reliability, and advanced interaction patterns.

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.

One browser process hosting isolated contexts A single browser process contains three independent contexts, each with its own cookie jar and storage, and each owning its own pages. Browser process chromium.launch() Context A (admin) own cookies + storage Context B (guest) own cookies + storage Context C (mobile) own cookies + storage Pages newPage()
One launched browser fans out into several contexts; each context is a sealed session that owns its own pages, cookies, and storage.

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.

Isolation and cost across browser, context, and page A matrix comparing a new browser launch, a new context, and a new page in the same context across cookie jar, local storage, startup cost, and memory footprint. Isolation property New browser New context Same context Cookie jar Local storage Startup cost Memory footprint isolated isolated shared isolated isolated shared ~1000 ms ~30 ms ~10 ms full process lightweight negligible
The context column is highlighted because it buys full cookie and storage separation at close to page-level cost — the default choice for a second identity.

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

  1. Launch the browser exactly once. Call chromium.launch() (or firefox/webkit) and reuse the returned browser for every context. Spawning a fresh browser per session multiplies process overhead for no isolation benefit, because contexts already provide it.
  2. Create one context per identity. Call browser.newContext() for each session you need. Pass storageState, viewport, locale, or permissions here so each context simulates a distinct user environment from the first navigation.
  3. Open pages from the right context. Call context.newPage() on the specific context — never browser.newPage() when you need isolation, because that creates an implicit throwaway context you cannot configure.
  4. Run concurrent work with Promise.all(). Wrap the parallel navigations and actions in Promise.all() so the contexts genuinely run side by side instead of serially, and never share a mutable object between them.
  5. Persist and reuse auth with storageState. Serialize a logged-in session once via context.storageState({ path: 'auth-state.json' }), then feed that file into newContext({ storageState }) for every future context that needs the same identity, as detailed in Reusing Login State with storageState.
  6. Close contexts before the browser. Call context.close() for each context, then browser.close() last. Wrap per-context work in try…finally so 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.

Lifecycle order for a multi-context run A timeline showing the browser launched first, contexts and pages created next, then contexts closed before the browser is closed last. chromium.launch() newContext() x3 newPage() + actions context.close() x3 browser.close() setup widens outward teardown folds inward
Time runs left to right: every context is created after the single launch and destroyed before the browser, so no renderer outlives its owner.

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.

Back to overview