Article · Network & API Mocking for Reliable Tests

Isolating APIRequestContext in Playwright Tests

Playwright's request fixture and the browser page share state more often than teams expect, and a leaked cookie or auth token bleeding between an APIRequestContext and the browser context is a classic source of order-dependent flakiness. This guide shows how to isolate API contexts with their own storageState, dispose them deterministically, and keep API-driven setup from polluting UI assertions. It extends the patterns in Playwright Route Mocking Strategies by focusing on the request layer rather than page-level interception.

13 sections URL: /network-api-mocking-for-reliable-tests/playwright-route-mocking-strategies/isolating-apirequestcontext-in-playwright-tests/
Shared versus isolated request contexts Two test flows: one where a shared cookie jar leaks auth between API and browser contexts, and one where each context owns its own storageState. Shared context (leaky) API context Browser context shared cookie jar Isolated contexts (stable) API ctx + state A Browser ctx + state B no shared state dispose() after each test frees sockets + cookies no bleed into next spec
Sharing a cookie jar leaks auth across contexts; isolated contexts with their own storageState and explicit disposal stay deterministic.

Root cause #

APIRequestContext is the object behind both playwright.request and the per-test request fixture. It maintains its own cookie jar, headers, and connection pool. When you create it from the same browser context (via context.request), it shares that context’s cookies. A login performed through the API fixture writes a session cookie that the browser page then reuses — and vice versa. In one test that is convenient; across a suite it becomes nondeterministic. Test B passes only because test A happened to authenticate first, and the failure surfaces the moment the runner reorders, shards, or retries specs.

The async execution model amplifies this. Contexts hold live sockets. If a context is never disposed, its keep-alive connections and pending cookies survive into the next test through the worker’s reused process. On CI, where workers run many specs back to back, a single undisposed context can silently authorize requests that should have failed with a 401, masking real auth regressions.

Shared cookie jar leaks auth context.request shares the browser's cookies, so an API login authorizes the page and creates order-dependent passes. API login shared cookie jarcontext.request page authorizedorder-dependent
A shared jar makes test B pass only because test A logged in first.

Step-by-step fix #

1. Create a dedicated API context with its own storageState #

Do not reach for context.request. Build a standalone context from the playwright fixture so it owns its cookie jar.

// tests/fixtures/api.js
const { test: base } = require('@playwright/test');

exports.test = base.extend({
  apiContext: async ({ playwright }, use) => {
    // Fresh storageState ({}) means zero inherited cookies/auth — full isolation.
    const ctx = await playwright.request.newContext({
      baseURL: process.env.API_URL,
      storageState: { cookies: [], origins: [] }
    });
    await use(ctx);
    // Trade-off: disposing every test costs a few ms but prevents socket/cookie bleed.
    await ctx.dispose();
  }
});

2. Authenticate the API context separately from the browser #

Persist the API session to its own file so it never overwrites the UI session.

// global-setup.js
const { request } = require('@playwright/test');

module.exports = async () => {
  const api = await request.newContext({ baseURL: process.env.API_URL });
  await api.post('/login', { data: { user: 'svc', pass: process.env.PW } });
  // Write to a distinct path so browser storageState and API storageState never collide.
  await api.storageState({ path: 'storage/api-state.json' });
  await api.dispose();
};

3. Wire isolated states into the project config #

Give the UI project and any API-driven setup distinct state files.

// playwright.config.js
module.exports = {
  globalSetup: require.resolve('./global-setup.js'),
  use: {
    // Browser pages load only the UI session — the API session lives elsewhere.
    storageState: 'storage/ui-state.json'
  }
};

4. Dispose explicitly when you create contexts ad hoc #

Inside a test that spins up a one-off context for seeding, always dispose it.

test('seed then assert UI', async ({ playwright, page }) => {
  const seed = await playwright.request.newContext({ baseURL: process.env.API_URL });
  await seed.post('/orders', { data: { sku: 'A1' } });
  // Dispose before the UI assertion so the seed context can't leak cookies into `page`.
  await seed.dispose();
  await page.goto('/orders');
  await expect(page.getByText('A1')).toBeVisible();
});
Dedicated context with empty state A standalone context built from playwright.request with empty storageState inherits zero cookies. playwright.requestnewContext storageState {}zero inherited auth full isolation
An empty storageState is what guarantees the API context owns nothing from the browser.

Pitfalls #

  • Using context.request for API calls — it shares the browser cookie jar. Mitigation: create standalone contexts with playwright.request.newContext().
  • Forgetting dispose() — leaks sockets and cookies into later specs. Mitigation: dispose in the fixture teardown so it always runs, even on failure.
  • One shared storageState file for both UI and API — auth from one overwrites the other. Mitigation: use distinct paths per session type.
  • Relying on test order for auth — passes locally, fails when sharded. Mitigation: each test authenticates its own context or loads an explicit state file.
  • Reusing a module-level context across files — state survives between specs. Mitigation: scope context creation to the fixture, not the module.
Distinct state files per session Writing UI and API sessions to separate storageState files stops one from overwriting the other. ui-state.jsonbrowser session api-state.jsonAPI session distinct paths → the two sessions never collide
Separate session files keep API and UI auth from overwriting each other.

Reliability targets #

Target Goal
Order-dependent flakiness < 0.5% of runs
Context disposal coverage 100% of created contexts
Auth-bleed incidents per 1k runs 0
CI pass rate (sharded) ≥ 99.5%
Context-isolation scorecard Targets for order-dependent flakiness, disposal coverage, auth-bleed incidents, and sharded pass rate. < 0.5%order flakiness 100%disposal 0auth-bleed ≥ 99.5%sharded pass
100% disposal coverage is what drives auth-bleed incidents to zero.

Frequently Asked Questions #

Q: Does the request fixture share cookies with page by default? A: Only if you derive it from the same browser context. The top-level request fixture and playwright.request.newContext() are independent unless you explicitly load the same storageState.

Q: When should I call dispose() versus letting Playwright clean up? A: Playwright disposes fixture-scoped contexts automatically, but any context you create manually inside a test must be disposed by you to free its sockets and cookies before the next test runs.

Q: Can I reuse one API context across a whole file for speed? A: You can, but it reintroduces cross-test state. Prefer per-test isolation; if performance matters, share read-only contexts only and never ones that mutate auth state.

Requests That Page Routes Never See #

The property that makes an API request context useful is also the one that surprises people: its requests do not pass through page routes. They are issued by the test process rather than by the browser page, so a route registered to intercept /api/invoices will not touch a seeding call to the same path.

That separation is a feature when it is understood. Seeding through the API is faster and more reliable than driving the interface, and keeping it outside the mocking layer means the seed genuinely creates data rather than being answered by a stub — which is exactly what a test needs when the browser side is mocked but the fixture must be real.

It is a trap when it is not. A suite that believes everything is mocked because page routes are in place can still be making real calls from its setup, which reintroduces a dependency on a live environment and, in write-heavy tests, on data other workers are also touching.

// API requests bypass page routes — state that deliberately in the test.
// Trade-off: seeding through the API reaches a real environment and therefore
// needs the same per-worker isolation as any other writer.
const api = await request.newContext({
  baseURL: process.env.API_URL,
  extraHTTPHeaders: { authorization: `Bearer ${await tokenForWorker(workerIndex)}` },
});
const created = await api.post('/invoices', { data: { reference: `INV-${workerIndex}` } });
expect(created.ok()).toBeTruthy();

Credentials, Sessions and Cross-Worker Interference #

An API context carries its own authentication state, and sharing that state is the most common way a well-isolated browser suite still interferes with itself.

Two contexts created with the same stored credentials act as the same user on the server. Browser-level isolation — a fresh context per test, empty storage, cleared cookies — does nothing about that, because the interference happens in the application’s data rather than in the browser. A test that edits a profile and another that asserts on it will race whenever they run concurrently as the same account, regardless of how clean each browser context is.

The fix is per-worker identity: an account, a tenant or an API key derived from the worker index, created once at start-up and reused within that worker. That keeps the number of accounts bounded, keeps failures reproducible because the mapping is stable rather than random, and removes the class of interference entirely.

Two smaller disciplines complete it. Dispose of the context when the test finishes, so connections and any server-side session are released rather than accumulating across a long run. And keep the credentials out of the recorded artefacts — traces and HAR archives capture request headers, so an authorisation token from a seeding call can end up committed alongside a fixture unless it is scrubbed.

Disposal and Connection Hygiene #

A request context holds connections and, on the server side, whatever session its credentials established. Leaving it undisposed accumulates both across a long run, which surfaces as connection-pool exhaustion on the application under test rather than as anything recognisable in the suite.

Disposing in the same scope that created the context — a fixture teardown, an afterEach, an explicit call at the end of a setup helper — keeps the count bounded. Where a context is created per worker rather than per test, disposal belongs in the worker’s own teardown, and the lifetime should be stated explicitly in the fixture so a reader knows how long the credentials remain active.

The related hygiene point is artifact hygiene: traces and archives capture request headers, so a context carrying a bearer token can put that token into a committed fixture unless the scrubbing step removes it. Treating credentials as something that must be redacted at capture time, rather than noticed in review, is the safer default.

Naming the context after its purpose — a seeding context, an assertion context, an admin context — makes its credentials and lifetime obvious at the call site, and it prevents the common drift where one general-purpose context accumulates every role the suite needs.