Steve Kinney

Route-Based Network Interception

The HAR lessons covered the “record everything, replay forever” approach to network isolation. That’s the right tool when you’re mocking a large API surface—dozens of endpoints, nested responses, pagination tokens. But sometimes you don’t need a full recording. You need to mock one endpoint, or block images, or simulate a 500 from the server, or strip a header before it reaches your app. That’s where page.route earns its keep.

Route-based interception is Playwright’s API for intercepting individual network requests and deciding what to do with them: serve a fake response, modify the real one, or kill the request entirely. It’s more explicit than HAR replay, more flexible, and—for small mocks—much less ceremony.

The basics: intercepting a request

page.route takes a URL pattern and a handler function. Every request matching the pattern goes through your handler instead of the real network:

await page.route('**/api/shelf', async (route) => {
  await route.fulfill({
    status: 200,
    contentType: 'application/json',
    body: JSON.stringify({ books: [{ title: 'Station Eleven' }] }),
  });
});

That’s it. Any request to a URL ending in /api/shelf gets your canned response instead of hitting the server. The test is deterministic, it’s fast, and you can read the mock right there in the test file.

You can also scope the interception to an entire browser context with context.route, which applies to every page in the context—including popups and opened links:

test.beforeEach(async ({ context }) => {
  await context.route('**/api/analytics/**', (route) => route.abort());
});

The difference matters. page.route only intercepts requests from that specific page. context.route intercepts requests from every page the context opens. For most test scenarios, page.route is what you want. Reach for context.route when you need to block something globally—analytics, tracking pixels, third-party scripts—across all pages in the test.

Three things you can do with a route

Every route handler ends by calling one of three methods on the route object. Each one answers a different question.

Fulfill: serve a fake response

route.fulfill replaces the real response entirely. The request never leaves the browser:

await page.route('**/api/shelf', async (route) => {
  await route.fulfill({
    status: 200,
    contentType: 'application/json',
    body: JSON.stringify({ books: [] }),
  });
});

This is the one you’ll reach for most often in tests. Mock the endpoint, control the response, assert on the UI that renders it. No network, no flakiness, no dependency on somebody else’s server.

You can fulfill with more than just a body. The full set of options includes status, headers, contentType, body, and path (serve a file from disk). For example, serving a static JSON fixture:

await route.fulfill({
  status: 200,
  path: 'tests/fixtures/shelf-response.json',
});

Abort: kill the request

route.abort stops the request cold. The browser gets a network error, as if the server was unreachable:

// Block all image requests.
await page.route('**/*.{png,jpg,jpeg,webp}', (route) => route.abort());

This is useful for two things. First, speeding up tests that don’t care about images, fonts, or third-party scripts—blocking them shaves time off every page load. Second, testing how your app handles network failures. If you want to verify that the shelf page shows an error state when the API is down, abort the API route and assert on the error UI:

await page.route('**/api/shelf', (route) => route.abort());
await page.goto('/shelf');
await expect(page.getByText('Unable to load your shelf')).toBeVisible();

You can also abort selectively by resource type instead of URL:

await page.route('**/*', (route) => {
  return route.request().resourceType() === 'image' ? route.abort() : route.continue();
});

Continue: let it through, maybe modified

route.continue sends the request to the real server, but lets you modify it on the way out. You can change the method, the URL, the headers, or the post data:

// Strip a custom header before the request reaches the server.
await page.route('**/*', async (route) => {
  const headers = route.request().headers();
  delete headers['X-Debug-Token'];
  await route.continue({ headers });
});

This is the least common of the three in test code, but it shows up when you need to simulate a specific client environment (changing the User-Agent, for example) or when you need to strip headers that your test infrastructure adds but that the server doesn’t expect.

Modifying responses

Sometimes you want the real response from the server, but with one thing changed—a field added, a field removed, a status code swapped. The pattern is: fetch the original response through the route, modify it, then fulfill with the modified version.

await page.route('**/api/shelf', async (route) => {
  // Fetch the real response.
  const response = await route.fetch();
  const json = await response.json();

  // Add a field the test needs.
  json.books.forEach((book) => {
    book.testId = `book-${book.id}`;
  });

  await route.fulfill({
    response,
    body: JSON.stringify(json),
    headers: {
      ...response.headers(),
      'content-type': 'application/json',
    },
  });
});

route.fetch sends the request to the real server and gives you the response object. You modify whatever you need, then call route.fulfill with the modified version. The browser sees the modified response as if it came from the server directly.

This is powerful for testing edge cases without building a separate test server. Want to test what happens when the API returns 500? Fetch the real response, ignore it, and fulfill with a 500. Want to test what happens when one field is missing? Fetch the real response, delete the field, fulfill. The real server provides the baseline; you provide the variation.

If you find yourself modifying responses for more than two or three routes in the same test, you’ve probably outgrown route-based interception. Switch to HAR replay with a pre-edited HAR file, or build a proper fixture system. Route handlers are great for surgical mocks; they get unwieldy when you’re mocking an entire API surface.

Glob URL patterns

Playwright uses its own simplified glob syntax for URL matching in page.route, page.waitForResponse, and everywhere else that accepts a URL pattern. A few rules worth internalizing:

  • A single * matches any characters except /. So https://example.com/*.js matches https://example.com/app.js but not https://example.com/scripts/app.js.
  • A double ** matches any characters including /. So **/*.js matches both of those.
  • ? matches a literal question mark, not “any single character.” This is different from shell globs. If you want to match any character, use *.
  • Curly braces {} match a comma-separated list of alternatives: **/*.{png,jpg,jpeg} matches all three image types.
  • The glob must match the entire URL, not just a substring. *.js won’t match anything because URLs start with https://.

When globs aren’t expressive enough, pass a regular expression instead:

await page.route(/openlibrary\.org\/search\.json/, async (route) => {
  // ...
});

Regex patterns match against the full URL string, so you don’t need to match the protocol and domain if you don’t care about them—a partial match is fine.

Monitoring network events

Sometimes you don’t want to intercept requests—you want to observe them. Playwright exposes request and response events on both the page and the context:

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

These fire for every request, including ones you’ve routed. They’re useful for debugging—if a route isn’t matching, the request event shows you exactly what URL the browser is requesting so you can fix your glob.

In test code, you’ll rarely need raw event listeners. page.waitForResponse (covered in The Waiting Story) is the right tool for “wait until this request finishes, then assert.” Event listeners are for when you need to collect all traffic for a test report or a failure dossier.

The service worker gotcha

If your app registers a service worker, that worker sits between the browser and the network—and it intercepts requests before Playwright’s route handlers see them. This means your page.route calls might not fire at all, because the service worker is serving cached responses instead of letting the requests through to the network layer.

The fix is straightforward:

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

Or in playwright.config.ts:

export default defineConfig({
  use: {
    serviceWorkers: 'block',
  },
});

This disables service workers entirely for the test context. Your routes work as expected, and you don’t lose anything—service worker caching is a production concern, not a test concern.

If you’re using Mock Service Worker (MSW) for API mocking in your app, it registers its own service worker. That worker will intercept requests before Playwright’s route handlers, making your routes invisible. If you want Playwright-level route interception, either disable MSW in the test environment or use Playwright’s built-in routing instead of MSW.

When to use routes vs. HARs

The decision is usually obvious once you state the question clearly:

  • One or two endpoints with known responses: Use page.route with route.fulfill. The mock lives in the test, it’s readable, it’s explicit.
  • Simulating errors or edge cases: Use page.route with route.abort or a custom route.fulfill with a non-200 status. HARs record successful sessions—they’re not built for error simulation.
  • A large API surface with many endpoints: Use HAR replay. Recording ten endpoints is easier than writing ten route handlers.
  • Response modification: Use route.fetch + route.fulfill when you want the real response with one thing changed. Use a modified HAR when you need that across many tests.

Both tools coexist. You can replay a HAR for most endpoints and add a page.route on top for the one endpoint you need to simulate failing. The route handler takes priority over the HAR replay for matching requests.

CLAUDE.md rules

## Route-based network mocking

- Use `page.route` with `route.fulfill` for mocking one or two endpoints
  with known responses. Use HAR replay for larger API surfaces.
- Use `route.abort` to simulate network failures and to block non-essential
  resources (images, fonts, analytics) that slow down tests.
- Never use `route.continue` to silently modify request headers without
  documenting why in a comment. Header manipulation is invisible in the
  test output and easy to forget about.
- If routes aren't intercepting as expected, check for service workers.
  Set `serviceWorkers: 'block'` in the test context when using route-based
  interception or HAR replay.

The one thing to remember

page.route is the surgical tool. HAR replay is the sledgehammer. Use route.fulfill when you know exactly what response you want, route.abort when you want to simulate failure, and route.continue when you want the real response with a tweak. When the mocks get big enough that route handlers are cluttering the test file, switch to HAR.

Additional Reading

Last modified on .