Playwright & Web Automation Hub

Playwright architecture, selector reliability, and advanced interaction patterns.

Browser Contexts & Isolation

A BrowserContext is the unit of isolation in Playwright. It shares the underlying engine process with every other context but keeps its own cookies, localStorage, IndexedDB, permissions, and cache — the equivalent of a fresh incognito profile that costs almost nothing to create. This separation is what makes parallel testing and concurrent scraping safe: two contexts cannot observe each other's state, so execution order stops mattering. This guide explains the context architecture, how to inject pre-authenticated sessions, how to run many contexts concurrently without exhausting memory, and how to diagnose the leaks that creep into long-running jobs. It sits beneath Playwright Setup & Core Architecture, which frames where isolation fits in the wider object model.

Isolated browser contexts inside one engine process One Browser process contains three independent BrowserContexts, each with its own cookies and storage and its own Page, sharing nothing. Browser (single engine process) Context 1 cookies + storage Page Context 2 cookies + storage Page Context 3 cookies + storage Page
Three contexts share one engine but no state — closing any one leaves the others untouched, which is why parallel tests stay deterministic.

Understanding the context architecture

When you call browser.newContext(), Playwright allocates a fresh storage partition inside the running engine rather than spawning a new process. That distinction is the entire performance story: launching a Browser is expensive and slow, but a BrowserContext is cheap enough to create per test. Each context owns an independent cookie jar, an isolated localStorage and IndexedDB, separate service workers, and its own permission grants. Nothing written in one context is visible in another.

This is why the test runner defaults to one context per test. A spec that logs in, mutates state, and asserts cannot pollute the next spec, because the next spec gets a clean partition. Page-level cleanup — clearing cookies, resetting storage between navigations inside a single shared session — is fragile by comparison: it depends on you remembering every mutable surface and clearing each one. Context isolation makes the clean slate the default rather than something you maintain by hand.

import { test, expect } from '@playwright/test';

test('contexts do not share state', async ({ browser }) => {
  const first = await browser.newContext();
  const second = await browser.newContext();

  const pageOne = await first.newPage();
  await pageOne.goto('https://example.com');
  await pageOne.evaluate(() => localStorage.setItem('token', 'abc'));

  // The second context never sees the first context's storage.
  const pageTwo = await second.newPage();
  await pageTwo.goto('https://example.com');
  const leaked = await pageTwo.evaluate(() => localStorage.getItem('token'));
  expect(leaked).toBeNull();

  await first.close();
  await second.close();
});

Injecting authenticated sessions with storageState

Logging in through the UI on every test is slow and flaky. The durable pattern is to authenticate once, save the resulting cookies and storage to a JSON file, and inject that file into every context with storageState. New contexts then start already signed in, skipping the login form entirely. This is the same mechanism that lets data-extraction jobs reach pages behind a session wall without re-authenticating per request.

import { test, expect } from '@playwright/test';

// Save session state once (typically in a setup project or global setup).
test('capture auth state', async ({ page, context }) => {
  await page.goto('https://app.example.com/login');
  await page.getByLabel('Email').fill('qa@example.com');
  await page.getByLabel('Password').fill('secret');
  await page.getByRole('button', { name: 'Sign in' }).click();
  await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
  // Persist cookies + localStorage to disk for reuse.
  await context.storageState({ path: 'auth.json' });
});

Reusing that file standardizes every context to the same starting point. Centralizing where storageState, viewport, locale, and route rules are applied keeps environments from drifting apart — the place to centralize is the config and fixture layer covered in Playwright Config & Fixtures. Because the storage boundary is enforced identically on every engine, the same auth.json works across Cross-Browser Execution targets without per-engine workarounds.

Configuring context options

A context is created with an options object that fixes its entire environment for the lifetime of every page inside it. Beyond storageState, the options that matter most for isolation are viewport, locale, timezoneId, geolocation, permissions, userAgent, httpCredentials, and extraHTTPHeaders. Setting these at the context level — rather than poking at them mid-test — keeps the environment immutable and reproducible, which is exactly what deterministic tests require.

import { test, expect } from '@playwright/test';

test('a fully specified context environment', async ({ browser }) => {
  const context = await browser.newContext({
    viewport: { width: 1440, height: 900 },
    locale: 'en-GB',                       // formats dates, numbers, currency
    timezoneId: 'Europe/London',          // pins Date() behavior for stable assertions
    geolocation: { latitude: 51.5, longitude: -0.12 },
    permissions: ['geolocation'],         // grant up front, no runtime prompt
    extraHTTPHeaders: { 'x-test-run': 'ci' },
  });
  const page = await context.newPage();
  await page.goto('https://maps.example.com');
  await expect(page.getByText('London')).toBeVisible();
  await context.close();
});

Pinning timezoneId and locale is the unsung fix for a whole class of flaky assertions: a test that checks a formatted date or a "2 hours ago" label passes locally and fails on a CI runner in another zone unless the context fixes both. Granting permissions up front avoids the runtime permission prompt that would otherwise block automation. Centralizing these option sets in the config and fixture layer — covered in Playwright Config & Fixtures — keeps every test on the same footing rather than each spec inventing its own environment.

Context per test versus context reuse

The runner gives each test its own context by default, and for the overwhelming majority of suites that is the right call: the cost of a context is small and the isolation is total. The exception is a read-only suite where every test starts from the same authenticated, never-mutated state — there, reusing a context (or at least a storageState file) across tests trades a little isolation for speed. The rule of thumb is that the moment a test mutates server or session state, it needs its own context so a later test cannot observe the mutation. When in doubt, default to per-test isolation; the performance cost is rarely the bottleneck, and order-dependent flakiness is far more expensive to debug than a few milliseconds of context creation.

Multi-context concurrency

High-throughput suites and scraping pipelines run many contexts at once. The naive approach — Promise.all over an unbounded list of contexts — works until the engine runs out of memory, because each context carries a predictable but non-trivial footprint. The disciplined approach caps the number of simultaneous contexts and processes work in bounded batches, synchronizing on real conditions rather than arbitrary delays.

import { test } from '@playwright/test';

test('bounded concurrent contexts', async ({ browser }) => {
  const targets = ['/a', '/b', '/c', '/d'];

  const results = await Promise.all(
    targets.map(async (path) => {
      const context = await browser.newContext({ userAgent: `worker-${path}` });
      const page = await context.newPage();
      await page.goto(`https://api.example.com${path}`);
      // Wait on the actual response, never on a fixed timeout.
      await page.waitForResponse(
        (r) => r.url().includes(path) && r.status() === 200,
      );
      const payload = await page.evaluate(() => document.body.innerText);
      await context.close();          // release each context as soon as it finishes
      return payload;
    }),
  );

  console.log('collected', results.length, 'payloads');
});

When the work list is large, replace the flat Promise.all with a concurrency limit so only N contexts are ever live. The full recipe for worker limits, dynamic concurrency scaling, and pooling under memory pressure is the focus of How to Configure Multiple Browser Contexts in Playwright, which turns the pattern above into a production-grade harness.

Diagnosing context leaks and race conditions

The most common failure in long-running automation is the unclosed context. Each one that escapes teardown holds memory until the process exits, and enough of them trigger an out-of-memory crash deep into a job. The defense is symmetry: every newContext() must have a matching close(), ideally in a fixture's teardown phase so it runs even when an assertion throws. Attaching event listeners surfaces what a context is actually doing, which turns vague hangs into concrete request logs.

import { test } from '@playwright/test';

test('trace a context to catch leaks', async ({ browser }) => {
  const context = await browser.newContext();
  const requests: string[] = [];
  // Event-driven tracing reveals every request the context issues.
  context.on('request', (req) => requests.push(req.url()));

  const page = await context.newPage();
  await page.goto('https://app.example.com');
  // Explicit selector wait with a strict ceiling surfaces races immediately.
  await page.waitForSelector('[data-testid="loaded"]', { timeout: 10_000 });

  console.log('issued', requests.length, 'requests');
  await context.close();   // symmetric teardown — no leak
});

Race conditions hide behind arbitrary sleeps. Replacing waitForTimeout with waitForSelector, waitForResponse, or auto-retrying expect assertions makes timing bugs deterministic instead of intermittent. When a leak or race only reproduces under load, capture a trace and replay it in the Trace Viewer described in Debugging & Test Observability to see exactly where the context stalled.

Frequently Asked Questions

What is the difference between a browser context and a new browser?

A new Browser launches a separate engine process, which is slow and memory-heavy. A BrowserContext is a fresh, isolated profile inside an already-running browser — cheap to create and tear down. Use contexts for per-test isolation and reserve new browsers for genuinely different engine configurations.

How do I reuse a logged-in session across tests?

Authenticate once, call context.storageState({ path: 'auth.json' }) to save cookies and storage, then pass storageState: 'auth.json' when creating contexts. New contexts start already signed in, so you skip the login flow on every test.

Why does my long-running scraping job run out of memory?

Almost always because contexts are created but never closed, or because too many run concurrently. Pair every newContext() with a close(), cap the number of simultaneous contexts, and process work in bounded batches so the live context count stays under control.

Back to overview