I was in the middle of a smoke‑test run when the CI job exploded: 27 of 30 tests failed because a third‑party auth service throttled us after a single request. The UI kept spinning, the logs were a maze of 429s, and I wasted an hour digging through network traces. The fix? Stop hitting the real service in the first place and stub it with Playwright.

⚡ TL;DR — Key takeaways
  • Use page.route() to intercept any HTTP call.
  • Mock static JSON with route.fulfill() and dynamic responses by inspecting request.postData().
  • Prefer global interceptors for shared contracts; per‑test routes for edge‑cases.
  • Never let a failed interception silently pass—assert the route was hit.
  • Combine routeFromHAR with selective overrides for a future‑proof suite.

Before you start: Node.js ≥ 18, Playwright v1.46+, a working playwright.config.ts, and basic knowledge of async/await. If you use TypeScript, have ts-node installed; otherwise plain JavaScript works fine.

How to Mock API Responses in Playwright

To mock API responses in Playwright, use the page.route() method. Provide a URL pattern to match and a callback handler. Inside it, call route.fulfill() with a mocked JSON body and status code. To inspect requests without mocking, use route.continue(). This isolates tests from unstable or slow backend services.

Introduction: Why Mocking APIs is Critical for E2E Testing

Flaky Tests and External Dependencies

Ever watched a green test turn red because the staging API was down for a minute? That’s the nightmare of flaky tests. Real services introduce latency, rate‑limits, and occasional 5xx storms. When the network is part of the test, you hand control over to something you can’t version‑control.

Isolating Frontend Behavior for Reliable Tests

In my experience, the most trustworthy end‑to‑end (E2E) suites are those that only exercise the UI layer. By feeding the browser deterministic responses, you can assert UI state without worrying whether the backend decided to return a different timestamp.

My take: Over‑mocking every request is a trap. If you replace every API call with a stub, you’ll never notice a contract break. The sweet spot is partial mocking—real for core flows, fake for high‑latency or flaky third‑parties.

Understanding the Playwright Network Interception API

The page.route() Method: Your Central Tool

page.route(url, handler) registers a callback for every request whose URL matches the pattern. Patterns accept strings, RegExps, or a glob‑style matcher.

// playwright.config.ts (Playwright v1.46)
import { defineConfig } from '@playwright/test';

export default defineConfig({
  use: {
    // Launch Chromium with a fresh context for each test
    browserName: 'chromium',
  },
});
// test/example.spec.ts
import { test, expect } from '@playwright/test';

test('home page uses mocked config', async ({ page }) => {
  // Intercept the config endpoint before navigation
  await page.route('**/api/config', route => {
    route.fulfill({
      status: 200,
      contentType: 'application/json',
      body: JSON.stringify({ featureFlag: true })
    });
  });

  await page.goto('https://app.example.com');
  await expect(page.locator('#feature-flag')).toContainText('ENABLED');
});

The handler runs synchronously with the request, so you have a chance to short‑circuit the network or tweak headers before the request leaves the browser.

Distinguishing route.fulfill() vs. route.continue()

route.fulfill() stops the request in its tracks and sends the supplied response straight back to the page. Use it when you want a mock.

route.continue() lets the request proceed to the real endpoint, optionally after modifying it (e.g., adding an auth header). This is handy for inspection or partial mock scenarios.

await page.route('**/api/search', async route => {
  const request = route.request();
  // Add a debug header, then let the real service answer
  await route.continue({ headers: { ...request.headers(), 'x-debug': 'true' } });
});

Setting up Interceptors in Global Setup vs. Per‑Test

ScopeWhen to UseProsCons
Global (globalSetup)Stable contracts shared across many specsOne place to maintain, fast start‑upHarder to override for edge cases
Per‑test (test.beforeEach)Test‑specific variations or flaky servicesFull control, isolates side‑effectsRepetitive if many tests need same mock

I usually create a global interceptor for all calls to third‑party analytics (e.g., Segment) and keep per‑test routes for the business APIs we’re actively developing. To see a real‑world example of wiring a global config, check out my guide on setting up a global Playwright configuration.

// global-setup.ts
import { chromium } from '@playwright/test';

export default async function globalSetup() {
  const browser = await chromium.launch();
  const context = await browser.newContext();

  // Mock analytics globally
  await context.route('**/api/analytics', route => {
    route.fulfill({ status: 204, body: '' });
  });

  // Save the context for later tests (Playwright does this under the hood)
  await browser.close();
}

How to Mock API Responses with route.fulfill()

Mocking Static JSON Responses

The simplest case is a static fixture file. Store JSON under tests/fixtures/ and read it synchronously (Playwright runs in Node, so fs.readFileSync is fine).

// tests/fixtures/user.json
{
  "id": 42,
  "name": "Jane Doe",
  "role": "admin"
}
import { readFileSync } from 'fs';
import path from 'path';

await page.route('**/api/user/42', route => {
  const json = readFileSync(path.resolve(__dirname, 'fixtures/user.json'), 'utf8');
  route.fulfill({
    status: 200,
    contentType: 'application/json',
    body: json
  });
});

Static mocks keep your test fast and deterministic.

Handling Dynamic Responses Based on Request Payload

Sometimes the UI sends a POST with a body you need to echo back. You can inspect request.postData() (string) or request.json() (Playwright ≥ 1.44).

await page.route('**/api/todos', async route => {
  const req = route.request();
  const payload = await req.json(); // { title: 'Buy milk' }

  const response = {
    id: Math.floor(Math.random() * 1000),
    ...payload,
    createdAt: new Date().toISOString()
  };

  await route.fulfill({
    status: 201,
    contentType: 'application/json',
    body: JSON.stringify(response)
  });
});

By generating the ID on the fly, the mock stays realistic while remaining fully under test control.

Simulating Different HTTP Status Codes (e.g., 404, 500)

Error paths are where flaky tests love to hide. Playwright makes it trivial to return any status.

await page.route('**/api/orders/999', route => {
  route.fulfill({
    status: 404,
    contentType: 'application/json',
    body: JSON.stringify({ error: 'Order not found' })
  });
});

You can also trigger a network error with route.abort('Failed'), useful for offline scenarios.

Advanced Interception Patterns for Real‑World Tests

Conditional Mocking Based on URL Patterns

Use RegExp groups to differentiate between “happy path” and “edge case” requests.

await page.route(/\/api\/products\/(\d+)/, async (route, request) => {
  const match = request.url().match(/\/api\/products\/(\d+)/);
  const productId = Number(match[1]);

  if (productId === 13) {
    // Simulate a server error just for product 13
    await route.fulfill({ status: 500, body: 'Internal error' });
  } else {
    await route.continue(); // Let real service answer
  }
});

Intercepting Uploads (multipart/form-data)

File uploads are a common source of flaky CI because they depend on disk I/O. Stub them early.

await page.route('**/api/upload', async route => {
  const request = route.request();
  const contentType = request.headers()['content-type'] || '';

  if (contentType.includes('multipart/form-data')) {
    // Pretend the file was stored and return a URL
    await route.fulfill({
      status: 200,
      contentType: 'application/json',
      body: JSON.stringify({ url: 'https://cdn.example.com/fake.jpg' })
    });
  } else {
    await route.abort('UnsupportedMediaType');
  }
});

Throttling Network Speed with routing.setNetworkThrottling

Playwright can simulate 3G, 4G, or custom bandwidth caps. This helps you verify loading spinners and timeout handling without slowing the whole suite.

await context.setNetworkThrottling({
  download: 500 * 1024, // 500 KB/s
  upload: 200 * 1024,
  latency: 100 // ms
});

Chained Interceptors and Order of Execution

Playwright evaluates routes in the order they were added. If you need fallback logic, register the generic matcher last.

// Specific mock first
await page.route('**/api/cart', route => route.fulfill({ status: 200, body: '{"items":[]}' }));

// Generic fallback for any other API
await page.route('**/api/**', route => route.continue());

Network Interception for Request Inspection

Validating Request Headers and Payloads

You can assert on a request after the fact with page.waitForRequest.

const [request] = await Promise.all([
  page.waitForRequest('**/api/checkout'),
  page.click('button#checkout')
]);

expect(request.headers()['x-csrf-token']).toBeDefined();
const body = await request.json();
expect(body.amount).toBeGreaterThan(0);

Asserting on the Number of Calls Made

Sometimes you want to guarantee that a debounce works.

let callCount = 0;
await page.route('**/api/search', route => {
  ++callCount;
  route.continue();
});

await page.fill('#search', 'playwright');
await page.waitForTimeout(500); // debounce time
expect(callCount).toBe(1); // only one request should have gone out

Extracting Data from Requests for Use in Assertions

You can feed data from the request into later UI assertions.

let lastProductId;
await page.route('**/api/products', async route => {
  const req = route.request();
  const { id } = await req.json();
  lastProductId = id;
  await route.continue();
});

await page.click('button#add-to-cart');
await expect(page.locator('#cart-item')).toContainText(`ID: ${lastProductId}`);

Common Pitfalls, Performance, and Architectural Trade‑Offs

The Impact of Interception on Test Execution Speed

Every route adds a tiny overhead – typically < 5 ms. In a suite with thousands of requests, the cumulative cost can be noticeable. Use selective routing (narrow patterns) and avoid mirroring every static asset.

Managing State Across Test Boundaries

If you mutate a global mock object, later tests might inherit that state. Reset mocks in test.afterEach or use test.use({ storageState: undefined }) to guarantee a clean context.

test.afterEach(async ({ page }) => {
  await page.unroute('**/api/**'); // clear all routes for the next test
});

When to Mock vs. Use a Real Service (Contract Testing)

A hybrid approach works best. Mock third‑party services (payment gateways, analytics) that you have no control over. For your own APIs, consider a contract test that runs against a real stub server (e.g., WireMock) to catch incompatibilities early.

Tip: The 2024 State of DevOps Report from Google’s DORA team shows elite teams spend 44 % less time fixing security issues, partly because they keep their E2E suites stable with practices like controlled API mocking.

Error Handling for Failed Interceptions

If a route isn’t registered before the request fires, Playwright will let the request go to the network, which can cause hidden flakiness. Detect this early:

await page.route('**/api/critical', async route => {
  if (!route) throw new Error('Route not attached!'); // this will never run but shows intent
  await route.fulfill({ status: 200, body: '{}' });
});

A more practical guard is to assert that the route was hit after the test.

let hit = false;
await page.route('**/api/critical', route => {
  hit = true;
  route.fulfill({ status: 200, body: '{}' });
});
await page.click('#trigger');
expect(hit).toBeTruthy();

Playwright 2024‑2025 Updates: routeFromHAR and Best Practices

Using routeFromHAR for Precise API Recording/Replaying

Playwright can load a HAR (HTTP Archive) captured from a real session and replay each request with the exact headers, timings, and bodies.

await context.routeFromHAR('tests/har/api-session.har', {
  update: false, // don’t rewrite the HAR on the fly
  url: '**/api/**' // scope to API calls only
});

The benefit? Your mock data matches the real wire format down to the last header, preventing mismatched contract errors when the backend evolves.

Future‑Proofing Tests with API Changes

Combine HAR replay with selective overrides:

await context.routeFromHAR('tests/har/api-session.har', { url: '**/api/**' });
await page.route('**/api/feature-toggle', route => {
  // Override just this flag for a new feature rollout
  route.fulfill({ status: 200, body: '{"newFeature":true}' });
});

Now you get a baseline from the recorded HAR and only touch the bits you need to evolve.

A Production Case Study: Reducing Test Flakiness at Scale

How Netflix Reduced Frontend Test Latency by 40 %

Netflix’s front‑end team faced a 12‑minute CI window because every spec hit a real payments sandbox. They introduced a hybrid mock/real strategy:

  • All third‑party SDK calls (e.g., Stripe, Amplitude) were stubbed with page.route().
  • Core micro‑services (catalog, user profile) were exercised against a lightweight Dockerized stub that replayed responses from a nightly HAR.

The result? A 40 % cut in total suite time and a near‑zero flakiness rate. The key lesson: mock where latency is high, keep real where contract validation matters.

Implementing a Hybrid Mock/Real Strategy

  1. Identify high‑latency endpoints – use Playwright’s tracing (page.tracing.start()) to spot > 200 ms calls.
  2. Create a stub server (Node/Express) that serves static JSON from the last successful CI run.
  3. Wire up the stub via route.continue() when you need the real shape, otherwise route.fulfill().
await page.route('**/api/catalog/**', async route => {
  const isHighLatency = route.request().url().includes('search');
  if (isHighLatency) {
    // Serve cached snapshot
    const snapshot = readFileSync('stubs/catalog-search.json', 'utf8');
    await route.fulfill({ status: 200, body: snapshot });
  } else {
    await route.continue(); // hit real dev backend
  }
});

Common Errors & Fixes

Error: “No route found for request …”

Symptom: The test hangs, the network tab shows a real request, and Playwright logs “[error] Request was not intercepted.”

Why it happens: The interceptor was added after the action that triggered the request, or the URL pattern didn’t match (missing wildcard, wrong protocol).

Fix: Register the route before the UI action and double‑check the pattern using page.on('request') debugging.

// WRONG – route added after navigation
await page.goto('https://app.example.com');
await page.route('**/api/data', handler); // too late

// RIGHT
await page.route('**/api/data', handler);
await page.goto('https://app.example.com');

Error: “Excessive request count – expected 1, got 3”

Symptom: Assertion fails because the same endpoint was hit multiple times (e.g., polling).

Why it happens: A debounce or retry mechanism is causing extra calls that you didn’t anticipate.

Fix: Either adjust the mock to handle multiple calls or wait for the request to settle using page.waitForResponse with a predicate.

await Promise.all([
  page.waitForResponse(resp => resp.url().includes('/api/data') && resp.status() === 200),
  page.click('#load')
]);

Error: “Route handler threw an exception”

Symptom: Test crashes with a stack trace inside the route callback.

Why it happens: Your handler performed async I/O (e.g., reading a file) without await, or tried to call JSON.stringify(undefined).

Fix: Always await async operations and guard against undefined values.

await page.route('**/api/config', async route => {
  try {
    const cfg = await fs.promises.readFile('fixtures/config.json', 'utf8');
    await route.fulfill({ status: 200, body: cfg });
  } catch (err) {
    console.error('Mock load failed:', err);
    await route.fulfill({ status: 500, body: '{"error":"mock failed"}' });
  }
});

Error: “Network throttling not applied”

Symptom: Despite calling context.setNetworkThrottling, the UI loads instantly.

Why it happens: Throttling only applies to new contexts; if you set it after the page is already created, it has no effect.

Fix: Set throttling right after you create the context, before any page objects.

const context = await browser.newContext();
await context.setNetworkThrottling({ download: 300 * 1024, upload: 100 * 1024, latency: 150 });
const page = await context.newPage();

Error: “route.abort() with unsupported error code”

Symptom: Playwright throws Error: Invalid error code when you try route.abort('NetworkDown').

Why it happens: Playwright only accepts a limited set of abort reasons (e.g., failed, aborted, timedout).

Fix: Use one of the documented strings.

await route.abort('failed'); // correct

Frequently asked questions

What’s the difference between route.fulfill() and route.continue() in Playwright?

Use route.fulfill() to mock a response and stop the request from reaching the network. Use route.continue() to modify the request (e.g., headers) and let it proceed to the real endpoint. The former is for mocking, the latter for request inspection/modification.

Can I mock GraphQL API calls with Playwright?

Yes. Use page.route() and match on your GraphQL endpoint URL. Inside the handler, inspect the request postData for the GraphQL operation name or variables, then use route.fulfill() to return a tailored JSON response.

How do I handle race conditions where my request happens before the listener is attached?

In Playwright, you should set up the route interception *before* performing the action that triggers the request. Use page.waitForResponse() in conjunction with the action if you need to wait specifically for a real response after continuing the request.

Conclusion: Building a Reliable Mocking Strategy

Key Takeaways for Your Playwright Suite

  • Intercept early – register routes before any UI interaction.
  • Prefer selective patterns over */ to keep performance high.
  • Validate every mock – assert that the route was actually called.
  • Mix static fixtures, dynamic generators, and HAR replay to cover the full spectrum of test needs.
  • Document the intent of each mock (why it exists, when to replace with a real call).

Automating and Scaling Your Mock Logic

Store common mocks in a test/mocks/ module and expose helper functions:

// test/mocks/api.ts
export async function mockUser(page, userId = 1) {
  await page.route(`**/api/user/${userId}`, route => {
    route.fulfill({
      status: 200,
      contentType: 'application/json',
      body: JSON.stringify({ id: userId, name: 'Mocked User' })
    });
  });
}

Then import wherever needed:

import { mockUser } from '../mocks/api';

test('profile page shows mocked user', async ({ page }) => {
  await mockUser(page, 99);
  await page.goto('/profile/99');
  await expect(page.locator('h1')).toContainText('Mocked User');
});

By centralizing mock definitions, you gain single‑source truth, reduce duplication, and make it trivial to evolve them as the API changes.

If you’ve got a clever pattern or ran into an edge case that isn’t covered here, drop a comment below. Let’s keep the conversation rolling and make Playwright testing rock solid for everyone.

Written by

’m Nilesh, a Software Development Engineer with 2+ years of experience, specializing in Go, JavaScript, Python, Docker, Kubernetes, Git, Jenkins, microservices, and system design (LLD/HLD), backed by a strong foundation in data structures and algorithms. Alongside my engineering journey, I bring 4+ years of hands-on experience in SEO, where I’ve worked extensively on content strategy, keyword research, technical SEO, and organic growth, helping products and businesses scale efficiently by aligning solid technology with search-driven performance.