Playwright & Web Automation Hub

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.

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.
  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.

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.

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.

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.

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.

Back to overview