Playwright

How to Intercept API Requests in Playwright

Playwright lets you capture, monitor, and modify network requests during browser automation. It transforms how you test APIs and scrape dynamic websites.

Intercepting API requests in Playwright starts with a single route handler, and the same mechanism covers capturing API responses, modifying requests, and handling real-world scenarios.

What is network interception in Playwright?

Network interception is Playwright's ability to pause a request inside the browser before it leaves for the network, and let your test decide what comes back. To make Playwright intercept API requests, you register a handler against a URL pattern with page.route(); every matching request is held, and your callback receives a Route object that controls the outcome.

The handler has three ways to resolve a request:

  • route.fulfill() answers from inside the test with the status, headers and body you supply, so the real endpoint is never called.
  • route.continue() lets the request reach the server, optionally with a changed URL, method, headers or POST body.
  • route.abort() kills the request, which is how you block trackers, fonts or images, and how you simulate a failed network call.

You can also fetch the real response inside the handler and fulfill with a patched version of it, which keeps the request contract honest while pinning the one field your assertion depends on.

What traffic you can reach

Interception covers anything the page issues over HTTP and HTTPS: document loads, static assets, XHR and fetch calls made by your front-end code. Playwright also routes WebSocket traffic, so real-time features are not a blind spot. Beyond hand-written stubs, page.routeFromHAR() replays a recorded HAR file, so a captured session becomes a set of mocks without writing a stub for each call.

Where handlers live

A handler registered with page.route() applies to a single page. browserContext.route() applies to every page in that context, which is the right level for rules you want everywhere, such as blocking analytics or CSS across a whole test file. Handlers stay active for the life of the page or context, so unroute() and unrouteAll() matter when one test's mocks would otherwise leak into the next. When several handlers match the same URL, the most recently registered one runs first and can fall through to the others.

All of this happens in the browser, with no change to application code, no proxy to configure and no browser extension to install. That is why interception is the standard fix for tests that inherit a backend's latency, shifting seed data and outages: you get deterministic responses, error paths you cannot trigger on demand against a live API, and faster runs when heavy or rate-limited calls are stubbed out.

Why Intercept Network Requests?

Network interception solves several testing and automation challenges.

You can mock API responses to test error scenarios without breaking production data. Extract JSON from background requests instead of parsing HTML.

Block images and stylesheets to speed up scraping by 70%. Validate that your UI sends correct API payloads.

Capture authentication tokens from login flows for later use.

Basic Network Interception with page.route()

The page.route() method intercepts requests matching a URL pattern.

Here's the simplest example:

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

test('intercept API request', async ({ page }) => {
  await page.route('**/api/users', route => {
    console.log('Request URL:', route.request().url());
    route.continue();
  });
  
  await page.goto('https://example.com');
});

This code intercepts all requests to /api/users endpoints.

The route.continue() call lets the request proceed normally. Without it, the request hangs indefinitely.

The ** wildcard matches any subdomain or path prefix.

Intercepting Specific HTTP Methods

You can filter by HTTP method to intercept only POST or PUT requests:

await page.route('**/api/users', async route => {
  const request = route.request();
  
  if (request.method() === 'POST') {
    console.log('POST request intercepted');
    console.log('Request body:', request.postDataJSON());
  }
  
  await route.continue();
});

This approach helps when the same endpoint handles multiple operations.

GET requests pass through while POST requests get logged.

Listening to Network Events

Playwright fires events for every request and response.

Use page.on() to listen without blocking requests:

page.on('request', request => {
  console.log('>>', request.method(), request.url());
});

page.on('response', response => {
  console.log('<<', response.status(), response.url());
});

await page.goto('https://example.com');

These events capture all network activity.

You see every asset load, API call, and redirect. This helps debug unexpected network behavior.

Unlike page.route(), these listeners don't modify requests.

Filtering Responses by Content Type

You can filter responses to capture only JSON APIs:

page.on('response', async response => {
  const contentType = response.headers()['content-type'];
  
  if (contentType && contentType.includes('application/json')) {
    const url = response.url();
    const json = await response.json();
    console.log('API Response:', url, json);
  }
});

This extracts JSON data from all API calls automatically.

Useful when discovering undocumented endpoints during scraping.

Using waitForResponse for Specific API Calls

When you need to capture a specific API response, use waitForResponse().

This method waits for a matching response before continuing:

test('capture login API response', async ({ page }) => {
  const responsePromise = page.waitForResponse('**/api/login');
  
  await page.getByRole('button', { name: 'Login' }).click();
  
  const response = await responsePromise;
  const data = await response.json();
  
  console.log('Auth token:', data.token);
});

Notice the promise is created before the click action.

Playwright waits for the response after the button triggers the request. This prevents race conditions.

You can also use predicates for complex matching:

const responsePromise = page.waitForResponse(
  response => response.url().includes('/api/') && response.status() === 200
);

Capturing Multiple Simultaneous Requests

Some actions trigger multiple API calls:

test('capture multiple API calls', async ({ page }) => {
  const response1Promise = page.waitForResponse('**/api/user');
  const response2Promise = page.waitForResponse('**/api/posts');
  
  await page.getByText('Load Dashboard').click();
  
  const [userRes, postsRes] = await Promise.all([
    response1Promise,
    response2Promise
  ]);
  
  console.log('User:', await userRes.json());
  console.log('Posts:', await postsRes.json());
});

Promise.all() waits for both responses to complete.

This ensures you capture all data before assertions.

Mocking API Responses

Mock responses replace real API calls with predefined data.

Use route.fulfill() to return custom JSON:

await page.route('**/api/products', route => {
  route.fulfill({
    status: 200,
    contentType: 'application/json',
    body: JSON.stringify([
      { id: 1, name: 'Test Product', price: 29.99 }
    ])
  });
});

await page.goto('https://shop.example.com');

The page displays your mocked product data.

The actual API never receives the request.

This approach tests how your UI handles different data shapes. You can mock empty arrays, error responses, or edge cases.

Mocking Error Responses

Test error handling by returning failed responses:

await page.route('**/api/checkout', route => {
  route.fulfill({
    status: 500,
    contentType: 'application/json',
    body: JSON.stringify({ error: 'Payment processor unavailable' })
  });
});

Your UI should display an appropriate error message.

This validates error handling without breaking production.

Modifying Requests Before They're Sent

You can change request headers, body, or method.

Here's how to add custom headers:

await page.route('**/api/**', async route => {
  const headers = route.request().headers();
  
  await route.continue({
    headers: {
      ...headers,
      'Authorization': 'Bearer test-token-123',
      'X-Custom-Header': 'custom-value'
    }
  });
});

This injects authentication into every API request.

Useful when testing with different user permissions.

Modifying POST Request Bodies

You can also change the request payload:

await page.route('**/api/users', async route => {
  if (route.request().method() === 'POST') {
    const original = route.request().postDataJSON();
    
    await route.continue({
      postData: JSON.stringify({
        ...original,
        testMode: true
      })
    });
  } else {
    await route.continue();
  }
});

This adds a testMode flag to every user creation.

The server processes modified data without UI changes.

Modifying Responses After They Arrive

Sometimes you need the real API response but want to tweak it.

Use route.fetch() to get the original response:

await page.route('**/api/prices', async route => {
  const response = await route.fetch();
  const body = await response.json();
  
  // Add 10% discount to all prices
  const modified = body.map(item => ({
    ...item,
    price: item.price * 0.9
  }));
  
  await route.fulfill({
    response,
    body: JSON.stringify(modified)
  });
});

This approach keeps the original status code and headers.

Only the response body changes.

This lets you test discount logic or promotional scenarios without backend changes.

Blocking Requests to Speed Up Tests

Block images, fonts, and stylesheets to accelerate page loads:

await page.route('**/*', route => {
  const resourceType = route.request().resourceType();
  
  if (['image', 'stylesheet', 'font'].includes(resourceType)) {
    route.abort();
  } else {
    route.continue();
  }
});

This prevents resource downloads entirely.

Your page loads 3-5x faster without visual assets.

This matters most when scraping at scale, where thousands of blocked image downloads add up.

Pattern Matching: Glob vs Regex vs Predicates

Playwright supports three matching strategies.

Glob patterns use wildcards for simple matching:

  • **/api/users matches any path ending in /api/users
  • **/*.{png,jpg} matches all images
  • **/api/** matches all API endpoints

Regular expressions offer more precision:

await page.route(/\/api\/users\/\d+/, route => {
  // Matches /api/users/123 but not /api/users/abc
  route.continue();
});

Predicate functions provide maximum flexibility:

await page.route(route => {
  const url = route.request().url();
  const hasToken = url.includes('token=');
  return url.includes('/api/') && !hasToken;
}, route => {
  route.continue();
});

Choose predicates when glob patterns become too complex.

Performance optimization with CDP sessions

Every handler you register with page.route() pauses matching requests inside the browser and hands them to your Playwright script before they go anywhere. That round trip costs time, and routed requests bypass the browser cache: the same logo, stylesheet, and font download again on every page you visit. On a single test that is invisible. Across a crawl of a few hundred pages on one domain, it is the difference between a run that finishes and a run that crawls.

Block at the browser level with CDP

Chrome DevTools Protocol blocks URLs inside Chromium itself, so nothing is proxied through your script and the cache keeps working for everything you did not block:

const client = await page.context().newCDPSession(page);

await client.send('Network.setBlockedURLs', {
  urls: ['*.png', '*.jpg', '*.css']
});

await client.send('Network.enable');

The Network domain has to be enabled for the block list to take effect, and the patterns are simple wildcards rather than the glob or regex matching you get from page.route(). The tradeoff is browser support: CDP is Chromium only. Firefox and WebKit runs still need page.route() with route.abort(), so a cross-browser suite ends up carrying both paths.

Be deliberate about what lands in the block list. Killing CSS and images speeds up text extraction, but it breaks screenshot comparisons, it can stop lazy-loaded content from ever firing its request, and a page that renders with zero images looks unusual to fingerprinting scripts.

Keep handlers narrow, and remove them

The cheapest optimization is matching less. A handler registered against ** inspects every document, font, and beacon the page issues, even when you only care about one endpoint. Tighten the pattern to the API path you actually want to touch and the rest of the traffic stays on the browser's fast path.

Scope matters too. When you intercept API requests in Playwright across a pool of tabs, register the handler once on the context with browserContext.route() instead of repeating it per page. Interception is not limited to HTTP either, since Playwright also routes WebSocket traffic through the same layer.

Handlers accumulate. If several match the same URL, the most recently registered one runs first and can fall back to the others, which makes stale handlers from an earlier test hard to debug. Call unroute() for a specific pattern or unrouteAll() in teardown so each run starts clean.

For repeat runs against traffic that does not change, recording once and replaying with page.routeFromHAR() removes the network from the equation entirely, which is both faster and deterministic.

Extracting Authentication Tokens

Capture tokens from login responses for later use:

let authToken = null;

page.on('response', async response => {
  if (response.url().includes('/api/login') && response.status() === 200) {
    const data = await response.json();
    authToken = data.accessToken;
    console.log('Captured token:', authToken);
  }
});

await page.goto('https://example.com/login');
await page.fill('#email', '[email protected]');
await page.fill('#password', 'password123');
await page.click('#login-button');

// Wait for token capture
await page.waitForTimeout(1000);

// Use token in subsequent requests
console.log('Using token:', authToken);

Store the token in a variable accessible across tests.

This eliminates repeated login flows.

Common pitfalls when you intercept API requests in Playwright

Most broken interception setups fail for one of a handful of reasons: the route never resolves, the handler was registered too late, something else answered the request first, or the pattern matched more than intended. Each one produces a different symptom.

Leaving a route unresolved

A handler that neither calls continue(), fulfill(), nor abort() leaves the request paused in the browser until the test times out. The page shows a spinner that never stops, and the error points at a missing element rather than the real cause. Check every branch of the handler, especially the else you added later: a conditional mock that only responds on one path will hang on the other.

Registering the handler after the request fires

Routes only apply to requests made after registration. Call page.route() before page.goto() or before the click that triggers the call, otherwise the request goes straight to the real backend and your mock looks like it was ignored.

The same ordering rule applies to waitForResponse. Create the promise before triggering the action:

// Correct
const responsePromise = page.waitForResponse('**/api/data');
await page.click('#load-data');
const response = await responsePromise;

// Wrong - may miss the response
await page.click('#load-data');
const response = await page.waitForResponse('**/api/data');

Awaiting the click first gives the response a chance to arrive before anything is listening, so the test fails intermittently, usually only on a fast CI machine.

Service workers answering before Playwright does

A registered Service Worker intercepts requests before Playwright sees them, so your handler never fires on a page that caches its API calls. Disable them in the context configuration:

const context = await browser.newContext({
  serviceWorkers: 'block'
});

Patterns that match more than you meant

The pattern **/* matches everything, including navigation, stylesheets, and scripts. Aborting on that pattern leaves you with a blank page and no obvious explanation. Be specific, or inspect the request inside the handler and let non-API traffic through:

await page.route('**/*', route => {
  const url = route.request().url();
  
  if (url.includes('/api/')) {
    route.abort();
  } else {
    route.continue();
  }
});

Overlapping handlers and leftover routes

When several handlers match the same URL, the most recently registered one runs first, and it can hand the request down the chain with route.fallback() instead of resolving it. That is useful for layering a per-test override on top of a shared default, and confusing when you forget the order.

Handlers also outlive the test that added them if you registered them on a shared object. Use unroute() to remove a specific handler and unrouteAll() to clear them between tests, otherwise a mock from an earlier case silently changes the behavior of a later one. Registering with browserContext.route() instead of page.route() applies the handler to every page in the context, including popups and new tabs, which is what you want for blanket rules like blocking analytics and what you do not want for a one-off stub.

Matching a single GraphQL operation

GraphQL sends every query and mutation to the same endpoint, so URL globs cannot tell one operation from another. Match with a predicate function instead and inspect the POST body of the request to find the operation name before deciding whether to fulfill it. Handlers that only look at the URL will intercept the whole GraphQL surface of the app.

Mocks drifting away from the real API

Hand-written stubs freeze a response shape that the backend keeps changing, and the tests stay green while the app breaks. Recording real traffic to a HAR file and replaying it with page.routeFromHAR() keeps the payloads deterministic without writing every stub by hand, and re-recording gives you a cheap way to refresh them when the contract moves.

Real-World Use Cases

API contract testing

Verify your frontend sends correct request payloads:

test('user creation sends valid data', async ({ page }) => {
  let capturedRequest = null;
  
  await page.route('**/api/users', route => {
    capturedRequest = route.request().postDataJSON();
    route.continue();
  });
  
  await page.fill('#name', 'John Doe');
  await page.fill('#email', '[email protected]');
  await page.click('#submit');
  
  expect(capturedRequest).toMatchObject({
    name: 'John Doe',
    email: '[email protected]'
  });
});

Web scraping with JSON extraction

Skip HTML parsing by capturing API responses:

const products = [];

page.on('response', async response => {
  if (response.url().includes('/api/products')) {
    const data = await response.json();
    products.push(...data.items);
  }
});

await page.goto('https://shop.example.com');
console.log('Extracted products:', products);

Testing with offline scenarios

Simulate network failures:

await page.route('**/api/**', route => route.abort('failed'));
await page.goto('https://example.com');
// Verify UI shows "Connection lost" message

Debugging Network Issues

Enable request/response logging for troubleshooting:

page.on('request', request => {
  console.log(`→ ${request.method()} ${request.url()}`);
});

page.on('response', response => {
  console.log(`← ${response.status()} ${response.url()}`);
});

page.on('requestfailed', request => {
  console.log(`✗ FAILED: ${request.url()} - ${request.failure().errorText}`);
});

Failed requests indicate blocked resources or network errors.

Check the error text to diagnose issues.

Conclusion

Playwright's network interception gives you complete control over HTTP traffic. You can mock APIs for testing, extract JSON for scraping, or block resources for performance.

Start with page.route() for request interception. Use page.on() for passive monitoring. Choose waitForResponse() when capturing specific API calls.

For production scraping, combine CDP sessions with selective blocking to maintain cache and optimize speed.

The key is matching your pattern strategy to the task. Use glob patterns for simplicity, regex for precision, and predicates for complex logic.