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 emulate a device and locale per context, 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 worker registrations, its own HTTP cache, and its own permission grants. Nothing written in one context is visible in another.

It helps to be precise about where the boundary actually falls, because the boundary is not "everything". A context partitions browser-side state: cookies, web storage, caches, service workers, permission grants, the credential store, and the emulation settings that pages inside it observe. It does not partition anything outside the engine. The server you are testing has one database, so two contexts hitting the same seeded account will still collide on the server side. The filesystem is shared too, which is why two contexts writing downloads or video to the same path will overwrite each other unless you give each one a distinct directory. Isolation in Playwright is a client-side guarantee, and treating it as an end-to-end guarantee is the single most common source of surprise in parallel suites.

The second thing worth internalising is that a context is immutable in the ways that matter. Options such as viewport, locale, timezoneId, userAgent, colorScheme, and storageState are fixed when the context is created. You cannot change a context's locale halfway through a test; you create a second context with the locale you want. This looks restrictive until you notice it removes an entire class of order-dependent bug, because no test can leave the environment in a state the next test inherits. A handful of surfaces do remain mutable at runtime — cookies via context.addCookies() and context.clearCookies(), permissions via context.grantPermissions() and context.clearPermissions(), route handlers via context.route() — and those exist precisely because they are additive rather than environmental.

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();
});

Prerequisites

Everything here assumes @playwright/test 1.40 or newer and a playwright.config.ts at the project root. You need somewhere writable for session files — a git-ignored .auth/ directory is the convention — and a target application that keeps its session in cookies or localStorage rather than in a native app shell. If you are running against a service that issues short-lived tokens, note the expiry now: it determines how often the setup step described below has to re-run. Familiarity with the fixture model in Playwright Config & Fixtures will make the teardown patterns below read as obvious rather than ceremonial.

The context lifecycle across a run

A context has a short, predictable life: created with a fixed set of options, populated with one or more pages, exercised, then closed. Understanding the ordering matters most when you introduce a setup project that authenticates once and hands a session file to every worker. That file becomes the join point between an ephemeral login context and every test context that follows, and getting the sequence right is what keeps a hundred parallel workers from each hammering the login endpoint.

Session state flowing from a setup context to worker contexts A sequence diagram showing the setup project creating a login context, writing auth.json, and each test worker injecting that file into a fresh context. Setup project Login context auth.json Worker context newContext() storageState() inject on create login context closes first Every worker re-injects the same file into a fresh context
The session file is the only thing crossing between contexts — no worker inherits a live cookie jar from another.

The ordering constraint is that the setup context must be fully closed before its state file is read, because storageState() serialises what the context holds at the moment of the call and the file is only flushed when the call resolves. Sequencing this with a dependent project — a setup project that the test projects declare as a dependency — is what Setting Up an Auth Setup Project covers in detail.

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' });
});

Two edge cases bite teams that adopt this pattern quickly. The first is scope: storageState captures cookies for every origin the context touched plus localStorage for the origins you visited, but it does not capture IndexedDB or sessionStorage. An application that stores its refresh token in IndexedDB will appear to be signed out despite a perfectly valid state file, and the fix is to seed that store explicitly with an addInitScript rather than to fight the serialiser. The second is expiry. A state file captured on Monday is worthless on Tuesday if the token lives an hour, so treat the file as a cache with a lifetime and regenerate it when the setup step detects an expired cookie rather than when a test mid-suite gets redirected to the login page.

The third consideration is multiplicity. Any application with roles needs one state file per role — admin.json, editor.json, viewer.json — and a project or fixture that selects the right one. Keeping each role in its own file preserves the isolation property: an admin test and a viewer test running at the same instant hold entirely separate cookie jars, which is exactly the arrangement described in Testing Multiple User Roles in Parallel. The wider design space — where files live, how they are refreshed, and how secrets stay out of the repository — is the subject of Authentication & Session State, and the mechanics of the injection itself are worked through in Reusing Login State with storageState.

Reusing the file also 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, colorScheme, 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
    colorScheme: 'dark',                  // drives prefers-color-scheme media queries
    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 keeps every test on the same footing rather than each spec inventing its own environment.

Two options deserve a warning. ignoreHTTPSErrors is convenient against a staging box with a self-signed certificate and dangerous everywhere else, because it will happily hide a genuine certificate regression. And userAgent overrides only the header and navigator.userAgent — it does not change the engine, so a Chromium context claiming to be Safari still behaves like Chromium in every way that matters. If you need real WebKit behaviour, run a WebKit project.

Emulating devices, locales, and timezones per context

Because emulation settings are context options, a mobile run is not a separate mode — it is a context created with a different options object. Playwright ships a devices registry that bundles viewport, device scale factor, user agent, touch support, and mobile flags into a single spread-able descriptor, and combining that with a locale and timezone gives a fully specified environment in four lines. This is the mechanism behind responsive-layout suites, and it composes cleanly with storageState: the same session file drops into a phone-shaped context and a desktop context alike.

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

test('same session, two device profiles', async ({ browser }) => {
  // Spread the device descriptor, then override only what differs.
  const phone = await browser.newContext({
    ...devices['iPhone 14'],       // viewport, UA, touch, deviceScaleFactor
    locale: 'de-DE',               // German number and date formatting
    timezoneId: 'Europe/Berlin',   // pins Date() so "today" is deterministic
    storageState: 'auth.json',     // reuse the session captured earlier
  });
  const mobile = await phone.newPage();
  await mobile.goto('https://app.example.com/orders');
  // The mobile layout collapses the table into a list of cards.
  await expect(mobile.getByRole('list', { name: 'Orders' })).toBeVisible();
  await phone.close();

  const desktop = await browser.newContext({
    viewport: { width: 1600, height: 900 },
    locale: 'de-DE',
    timezoneId: 'Europe/Berlin',
    storageState: 'auth.json',
  });
  const wide = await desktop.newPage();
  await wide.goto('https://app.example.com/orders');
  // Same data, table presentation — one assertion per breakpoint.
  await expect(wide.getByRole('table', { name: 'Orders' })).toBeVisible();
  await desktop.close();
});

Note that isMobile and hasTouch change how the engine dispatches input, so a tap-driven interaction can pass on a phone profile and fail on a desktop one for entirely legitimate reasons. The full matrix — which descriptors exist, how emulated media and reduced motion interact with a design system, and when emulation stops being a faithful proxy for a real handset — is covered in Emulating Devices, Locales and Timezones, which turns these options into a per-breakpoint project matrix.

Scoping network rules and permissions to a context

Route handlers registered with context.route() apply to every page in that context and to no other context, which makes them the natural place to stub a dependency for one scenario without touching the rest of the suite. The same is true of permission grants: context.grantPermissions() affects only the pages in that partition. This context-level scoping is what lets one worker run against a stubbed payment gateway while another worker runs against the real one, in the same process, at the same time.

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

test('stub an origin for one context only', async ({ browser }) => {
  const stubbed = await browser.newContext();
  // Applies to every page created in this context, and nowhere else.
  await stubbed.route('**/api/pricing', async (route) => {
    await route.fulfill({
      status: 200,
      contentType: 'application/json',
      body: JSON.stringify({ currency: 'EUR', amount: 4200 }),
    });
  });
  // Clipboard access granted only inside this partition.
  await stubbed.grantPermissions(['clipboard-read'], {
    origin: 'https://app.example.com',
  });

  const page = await stubbed.newPage();
  await page.goto('https://app.example.com/checkout');
  await expect(page.getByTestId('total')).toHaveText('42,00 €');

  const live = await browser.newContext();          // no route handler here
  const other = await live.newPage();
  await other.goto('https://app.example.com/checkout');
  // The unstubbed context still hits the real endpoint.
  await expect(other.getByTestId('total')).not.toHaveText('42,00 €');

  await stubbed.close();
  await live.close();
});

Ordering matters: register routes before the first navigation, or the initial request escapes the handler. When a route handler must survive across many tests, hoist it into a fixture rather than repeating it, and remember that context.unroute() exists for the rare case where a single test needs to stop intercepting partway through. The broader interception vocabulary lives in Network Interception Basics.

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.

Comparison of per-test contexts and reused contexts A four-row matrix comparing a fresh context per test against a context reused across tests on isolation, cost, failure modes and mutation safety. Property Context per test Reused context Isolation guarantee Total Shared cookies Setup cost per test A few milliseconds Amortized Order-dependent failures Structurally absent Likely Tests that mutate state Safe Unsafe
Reuse buys back a few milliseconds per test and costs the property that makes a suite debuggable — only take that trade in a strictly read-only suite.

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. If context creation genuinely does show up in your profile, the right lever is a worker-scoped fixture that amortises the expensive part — a seeded database tenant, a compiled bundle — while still handing each test a fresh context, the pattern described in Worker-Scoped Fixtures for Expensive Setup.

Driving two users in one test

Isolation is usually framed as keeping tests apart, but the same property makes multi-actor scenarios possible inside a single test. Two contexts in one test are two independent users: a seller listing an item and a buyer bidding on it, an agent and a customer in a chat, an admin revoking a permission while a viewer holds an open session. Because each context has its own cookie jar, both can be signed in as different people at the same moment — something a single browser profile cannot do.

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

test('a message from one user reaches the other', async ({ browser }) => {
  // Two contexts, two sessions, one test — neither can see the other's cookies.
  const agentContext = await browser.newContext({ storageState: 'agent.json' });
  const customerContext = await browser.newContext({ storageState: 'customer.json' });

  const agent = await agentContext.newPage();
  const customer = await customerContext.newPage();
  await Promise.all([
    agent.goto('https://app.example.com/inbox/42'),
    customer.goto('https://app.example.com/chat/42'),
  ]);

  await agent.getByRole('textbox', { name: 'Reply' }).fill('Shipping today.');
  await agent.getByRole('button', { name: 'Send' }).click();

  // Web-first assertion polls until the websocket delivers the message.
  await expect(customer.getByText('Shipping today.')).toBeVisible();

  await Promise.all([agentContext.close(), customerContext.close()]);
});

The discipline here is to close both contexts even when the assertion throws — wrap them in a fixture if the pattern repeats — and to assert with auto-retrying expect rather than a fixed wait, because the delivery latency between the two contexts is real network time you do not control.

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');
});

Budget roughly 30-60 MB of resident memory per idle context and considerably more once a heavy single-page application is loaded, then divide the container's memory limit by that figure to get a ceiling. Subtract a margin for the engine itself and for video or trace capture, both of which buffer to memory before flushing. On a 4 GB CI runner, eight to twelve concurrent contexts is a realistic starting point; the number goes down sharply if you are recording video.

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, while Running Parallel Scrapers with Worker Pools applies the same accounting to extraction jobs.

Failure modes and debugging

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.

The two exits from an active browser context A state machine showing a context moving from created to active, then either to closed via an explicit close call or to a leaked state when teardown is skipped. created active (pages open) closed leaked newPage() close() throw before teardown A leaked context keeps its cookie jar and page tree resident
Only one transition out of the active state is safe; every path that skips close() parks memory until the process exits.

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
});

Three other failure modes recur often enough to name. A context that appears signed out despite a valid state file is usually a cookie domain mismatch: the file was captured against localhost and replayed against 127.0.0.1, which the cookie jar treats as a different host. A context whose assertions fail only on CI is usually a timezone or locale difference the options never pinned. And a suite that passes in isolation but fails in parallel is almost never a context bug — it is shared server-side state, and the fix belongs in test data seeding rather than anywhere in the browser.

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.

CI/CD considerations

Contexts behave the same in CI as locally, but the constraints around them change. Container memory is the binding limit, so set workers from the runner's actual memory rather than from its core count — a machine with eight cores and 4 GB cannot usefully run eight workers each holding a video-recording context. Give every worker its own output directory so downloads, traces, and videos from parallel contexts never collide on disk.

Session files need a deliberate policy. Generate them in a setup project on every run rather than committing them, keep them out of version control, and make sure the artifact upload step excludes them — a state file is a live credential, and publishing it in a build artifact is a credential leak in every meaningful sense. If your pipeline shards across machines, each shard runs its own setup step and produces its own file; sharing one file across machines through a cache introduces an expiry race for no real gain. Sharding mechanics are covered in Running Playwright Tests in GitHub Actions with Sharding, and the container-level concerns in CI/CD Integration.

Finally, enable trace capture on first retry rather than always. Tracing attaches to the context and buffers events, so a suite with tracing on every context pays both memory and time for runs that were going to pass anyway.

Deep dives beneath this guide

Emulating Devices, Locales and Timezones takes the emulation options above and builds a per-breakpoint project matrix, including which device descriptors are faithful proxies for real hardware and which are not.

How to Configure Multiple Browser Contexts in Playwright turns the bounded-concurrency sketch into a reusable pool with worker limits, backpressure, and recovery from a crashed context.

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.

Does storageState capture IndexedDB and sessionStorage?

No. The serialised file holds cookies and localStorage for the origins the context visited, and nothing else. Applications that keep their refresh token in IndexedDB will look signed out despite a valid file, and the remedy is to seed that store with an init script when the context is created rather than expecting the state file to carry it.

Can I change a context's viewport or locale mid-test?

The viewport can be resized on an individual page, but locale, timezone, user agent, and color scheme are fixed at creation and cannot be reassigned. If a scenario needs a second environment, create a second context with the options you want — that immutability is deliberate, because it prevents one test from leaving the environment altered for the next.

How many contexts can I run at once on a CI runner?

Divide the container's memory limit by roughly 60 MB per idle context, then subtract a margin for the engine and for any video or trace buffers. A 4 GB runner comfortably sustains eight to twelve concurrent contexts against a typical single-page application, and far fewer when video recording is enabled on every one of them.

Back to overview