Network Interception and API Mocking in Playwright

Why intercept network traffic

Playwright can sit between your page and the network. Every request the page makes, from document loads to fetch calls, can be inspected, answered with fake data, rewritten, or dropped before it leaves the browser.

Three problems make this worth learning. First, isolation: when a test fails because a staging backend was slow, the failure tells you nothing about your frontend. Mocking the API removes the backend from the equation. Second, error states: you cannot ask production to return a 500 on demand, but your error banner still needs a test. Third, noise: analytics scripts, ad networks, and chat widgets slow tests down and fail in ways you do not control. Blocking them makes the suite faster and quieter.

Matching requests with page.route

page.route(pattern, handler) registers an interceptor. The pattern can be a glob string, a regular expression, or a predicate function that receives a URL object.

// Glob: ** matches any number of path segments
await page.route('**/api/todos', async (route) => { /* ... */ });

// Regex: useful for matching several endpoints at once
await page.route(/\/api\/(orders|invoices)/, async (route) => { /* ... */ });

// Predicate: full programmatic control
await page.route(
  (url) => url.pathname.startsWith('/api/') && url.searchParams.has('draft'),
  async (route) => { /* ... */ }
);

Register routes before the navigation or interaction that triggers the request. A route added after page.goto() misses everything the page loaded on the way in.

Inside the handler, route.request() exposes the intercepted request, so you can branch on method or headers:

await page.route('**/api/todos', async (route) => {
  if (route.request().method() === 'GET') {
    await route.fulfill({ json: [] });
  } else {
    await route.continue();
  }
});

Mocking responses with route.fulfill

route.fulfill answers the request without touching the network. You control the status, headers, and body:

await page.route('**/api/user', async (route) => {
  await route.fulfill({
    status: 200,
    contentType: 'application/json',
    body: JSON.stringify({ name: 'Leo', plan: 'pro' }),
  });
});

For JSON, the json option does the serialization and sets the content type for you:

await page.route('**/api/user', async (route) => {
  await route.fulfill({ json: { name: 'Leo', plan: 'pro' } });
});

A typical mocked test looks like this:

test('shows the empty state when there are no monitors', async ({ page }) => {
  await page.route('**/api/monitors', async (route) => {
    await route.fulfill({ json: [] });
  });

  await page.goto('/dashboard');
  await expect(page.getByText('No monitors yet')).toBeVisible();
});

The page never learns it was lied to. It fires a real fetch, receives a real-looking response, and renders accordingly. Your assertions then check the rendered result as usual.

Blocking requests with route.abort

route.abort fails the request outright. The classic use is stripping third-party weight out of tests:

await page.route(/googletagmanager|hotjar|intercom/, (route) => route.abort());

// Block all images when the test does not care about them
await page.route('**/*.{png,jpg,jpeg,webp}', (route) => route.abort());

Aborted requests surface in the page as network errors, so only block things the page tolerates losing. Blocking your own API to simulate an outage is also legitimate: route.abort('connectionrefused') lets you test what users see when the backend is unreachable.

Modifying requests with route.continue

route.continue sends the request to the real network, optionally rewritten. Headers are the common case:

await page.route('**/api/**', async (route) => {
  const headers = { ...route.request().headers(), 'x-e2e-test': '1' };
  await route.continue({ headers });
});

You can also override method, postData, and url. This is handy for pointing a hardcoded endpoint at a test double without rebuilding the app.

Handler order and fallback

When several routes match the same request, Playwright runs them in reverse registration order: the last route registered gets the request first. A handler that calls route.fallback() passes the request down to the next matching handler, while route.continue() ends the chain and hits the network immediately.

// Registered first, runs last: default catch-all
await page.route('**/api/**', (route) => route.fallback());

// Registered second, runs first: specific override for one test
await page.route('**/api/flags', (route) => route.fulfill({ json: { beta: true } }));

This ordering lets you define broad defaults in a fixture and override single endpoints per test. Use page.unroute() or page.unrouteAll() to remove handlers when a test needs to restore real traffic.

Testing error states

Error paths are where mocking earns its place, because the interesting failures are exactly the ones you cannot produce on demand.

test('shows an error banner when the API returns 500', async ({ page }) => {
  await page.route('**/api/monitors', (route) =>
    route.fulfill({ status: 500, json: { error: 'Internal Server Error' } })
  );

  await page.goto('/dashboard');
  await expect(page.getByRole('alert')).toContainText('Something went wrong');
});

Slow responses work the same way. Delay inside the handler before fulfilling, then assert that the loading state actually shows:

await page.route('**/api/monitors', async (route) => {
  await new Promise((resolve) => setTimeout(resolve, 3000));
  await route.fulfill({ json: [] });
});

await page.goto('/dashboard');
await expect(page.getByTestId('skeleton')).toBeVisible();

Because the delay happens in your handler rather than in the app, this stays deterministic. The waits and timeouts guide covers what Playwright does while that skeleton is on screen.

Editing real responses

Sometimes you want the real response with one field changed. route.fetch() performs the actual request, then you fulfill with an edited copy:

await page.route('**/api/user', async (route) => {
  const response = await route.fetch();
  const json = await response.json();
  json.plan = 'enterprise';
  await route.fulfill({ response, json });
});

Passing response preserves the original status and headers, and json swaps the body. This pattern keeps the payload realistic, which matters when the response has thirty fields and your fixture would only remember five of them.

Asserting on real traffic with waitForResponse

Interception is not the only tool. When the request should stay real and you just want to verify it happened, page.waitForResponse observes without interfering:

const responsePromise = page.waitForResponse(
  (response) => response.url().includes('/api/orders') && response.status() === 201
);
await page.getByRole('button', { name: 'Place order' }).click();

const response = await responsePromise;
expect(await response.json()).toMatchObject({ status: 'confirmed' });

Start the wait before the click, or the response can arrive while nobody is listening. If you find yourself asserting on many raw responses, some of that coverage may belong in API tests instead, where there is no browser in the way.

Recording and replaying with HAR

Hand-writing mocks for a page that makes twenty requests gets old. Playwright can record all traffic into a HAR file and replay it later:

// Record: run once with update: true to capture real responses
await page.routeFromHAR('./hars/dashboard.har', {
  url: '**/api/**',
  update: true,
});

// Replay: the same line with update: false serves saved responses
await page.routeFromHAR('./hars/dashboard.har', {
  url: '**/api/**',
  update: false,
});

Requests with no HAR entry are aborted, which quickly tells you when the app starts calling something new. Re-run with update: true whenever the API changes. HAR replay suits read-heavy pages well; for endpoints where the test cares about exact payloads, explicit route.fulfill calls stay easier to review.

Page routes vs context routes

Everything above also exists on the browser context. context.route() applies to every page in the context, including popups, which per-page routes do not cover:

await context.route(/analytics/, (route) => route.abort());

Put suite-wide blocking and defaults at the context level, usually in a fixture, and keep test-specific mocks on the page.

Know when to stop mocking

A test that mocks every request proves only that your frontend renders the responses you invented. The API can rename a field, and the mocked suite stays green while production breaks. That is the trap.

A reasonable split: mock third-party services you do not control, error states you cannot trigger, and endpoints that are slow or rate-limited. Keep your own backend real in a small set of core journey tests, signup, login, checkout, so at least one layer of the suite exercises the actual contract between frontend and API.

Monitoring inverts the ratio entirely. A synthetic check exists to tell you whether the real system works, so a mocked dependency defeats the purpose. Scripts you promote to production checks should run against real traffic, with interception reserved for blocking third-party noise at most.

Run it in production

Playwright scripts that exercise real endpoints make good production monitors. Hyperping Browser Checks run them on a schedule from multiple regions against your live app, with no mocks in the path, so a failing API surfaces as an alert instead of a support ticket.

Get started

Start monitoring in the next 5 minutes.

Stop letting customers discover your outages first. Set up monitoring, status pages, on-call, and alerts before your next coffee break.

14 days free trial. No card required.