Playwright architecture, selector reliability, and advanced interaction patterns.

Intercepting and Modifying Network Requests

Mocking replaces a response wholesale, but many tests need the real server's data with a small change applied in flight: an auth header injected, a query parameter rewritten, a single field patched in the response, or an analytics beacon blocked entirely. Playwright's route() handler gives you three tools beyond fulfill()route.continue() with overrides, route.fetch() paired with route.fulfill(), and route.abort() — that let the request keep its connection to the live backend while you reshape exactly the bytes you care about. This page covers when to modify rather than mock, how each technique maps to a concrete handler, and how overlapping handlers resolve when several rules match the same URL.

Three ways a route handler can modify traffic An intercepted request can be continued with overrides, fetched then fulfilled with edits, or aborted before it reaches the network. Page request route() continue(overrides) headers, method fetch() + fulfill() edit real body abort(reason) block beacon
Modification keeps the live connection: continue rewrites the outgoing request, fetch-then-fulfill rewrites the incoming response, abort cancels it.

Root cause: the response is right but one detail is wrong

Pure mocking solves non-determinism by inventing data, but it loses contact with the real backend, so it cannot prove the integration works and it goes stale silently. Many failures are narrower than that: the staging API needs a tenant header the test harness does not send, a third-party tracker slows the page and pollutes traces, or one volatile field (a timestamp, a generated id) breaks an otherwise valid assertion. For these you want the real round trip with a surgical edit, which is what route.continue(), route.fetch(), and route.abort() provide on top of Network Interception Basics, itself part of Advanced Interactions & Test Assertions.

The distinction matters because the two approaches fail differently. A mock fails closed: when the backend changes its contract, the mock keeps returning the old shape and the test stays green while production breaks. A modification fails open: if the backend renames the field you were patching, the patch becomes a no-op and the assertion fails immediately, which is the signal you wanted. Modification therefore belongs on the handful of requests whose payload is genuinely volatile, while everything else should either run untouched or be replaced outright with a recorded fixture such as a HAR file.

Minimal reproducible example

The handler below lets the real /api/profile request go out, but injects an auth header on the way and patches the volatile lastSeen field on the way back so the assertion is stable.

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

test('profile request is authenticated and response is normalized', async ({ page }) => {
  await page.route('**/api/profile', async (route) => {
    // 1. Fetch the REAL response, applying an outgoing header override.
    //    route.fetch() performs the network request the page would have made.
    const response = await route.fetch({
      headers: {
        ...route.request().headers(), // keep the browser's original headers
        'x-tenant': 'acme',            // inject the header the harness lacks
      },
    });

    // 2. Read the genuine JSON body returned by the backend.
    const body = await response.json();

    // 3. Patch only the volatile field; everything else stays real.
    body.lastSeen = '2026-06-19T00:00:00Z';

    // 4. Fulfill the page's request with the edited body but the real status.
    await route.fulfill({
      response,                       // reuse status + headers from the real response
      body: JSON.stringify(body),     // override only the body
    });
  });

  await page.goto('/account');

  // The UI shows real data, but the normalized timestamp makes this deterministic.
  await expect(page.getByText('Last seen: 2026-06-19')).toBeVisible();
});

Four actors take part in that handler, and the order they act in explains why route.fetch() exists as a separate call from route.continue(). The page issues the request, Playwright suspends it and hands it to your callback, your callback performs the round trip itself, and only then does the browser receive bytes. Because your code owns the middle of that sequence, it can see the real payload before the page ever does — which is precisely the window continue() does not give you.

Lifecycle of a fetch-then-fulfill interception A sequence diagram showing the page, the route handler, and the real server exchanging a request, a live response, an in-handler edit, and the fulfilled body. Page Route handler Real server GET /api/profile route.fetch() + headers real 200 + JSON body patch lastSeen field fulfill(edited body)
The handler owns the middle of the exchange, so it can read the genuine payload and hand the page an edited copy without the page ever seeing the original.

Step-by-step fix

  1. Decide modify versus mock. If you need the live backend's data and only want to nudge one detail, modify. If you want to eliminate the dependency completely, mock with route.fulfill() per Mocking API Responses with Playwright instead.
  2. Rewrite the outgoing request with route.continue(). Pass { headers, method, postData, url } to change what leaves the browser while still hitting the real server. Spread route.request().headers() first so you only override the keys you mean to.
  3. Edit a real response with route.fetch() then route.fulfill(). Call route.fetch() to perform the genuine request, read its body, mutate the fields you need, then fulfill({ response, body }) so the status and headers stay authentic and only the body changes.
  4. Abort noise with route.abort(). Cancel analytics, ads, fonts, or third-party beacons by matching their URL and calling route.abort('blockedbyclient'). This speeds tests and keeps traces clean without touching application requests, the same lever used for blocking images and fonts to speed up scraping.
  5. Scope and order your handlers. Register more specific globs before broad ones; Playwright runs the most recently added matching handler first, and an unhandled route falls through to the network. Use context.route() to apply a rule to every page in a browser context.
  6. Always settle the route. Each handler must call exactly one of continue, fulfill, fetch+fulfill, or abort. A handler that returns without settling hangs the request until it times out.

Handler ordering, scope, and fallback

Real suites rarely have one rule. A project fixture blocks images for every test, a shared setup adds a tenant header to every API call, and one spec needs a single endpoint reshaped. All three match GET /api/profile?expand=1, so the resolution order decides the outcome. Playwright evaluates page-level handlers before context-level ones, and within each level it checks the most recently registered handler first. A handler that decides the request is not its business calls route.fallback() to hand it down the chain rather than settling it, which lets you layer narrow overrides on top of broad defaults without duplicating the broad logic.

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

test('layered route rules resolve most recent first', async ({ page, context }) => {
  // Registered first at context level, so it is consulted last: block heavy assets.
  await context.route('**/*.{png,jpg,webp}', (route) => route.abort('blockedbyclient'));

  // Context-level default: every API call carries the tenant header.
  await context.route('**/api/**', (route) =>
    route.continue({
      headers: { ...route.request().headers(), 'x-tenant': 'acme' },
    }),
  );

  // Page-level rule wins over both, but only for the expanded profile call.
  await page.route('**/api/profile*', async (route) => {
    const url = new URL(route.request().url());
    if (!url.searchParams.has('expand')) {
      await route.fallback(); // not our case: defer to the context-level handler
      return;                 // returning without fallback would hang the request
    }
    const response = await route.fetch();     // real round trip, real status
    const body = await response.json();
    body.plan = 'enterprise';                 // reshape one field for this spec
    await route.fulfill({ response, body: JSON.stringify(body) });
  });

  await page.goto('/account?expand=1');
  await expect(page.getByRole('heading', { name: 'Enterprise plan' })).toBeVisible();
});
Registration order versus evaluation order for route handlers Three route rules listed in the order they were registered, alongside the reversed order in which Playwright evaluates them for a matching request. Registration order Evaluation order 1. **/*.png abort() 2. **/api/** continue() 3. **/api/profile fetch() 3. **/api/profile 2. **/api/** 1. **/*.png reversed GET /api/profile settles in handler 3 unless it calls route.fallback()
The last handler registered is the first one consulted, so narrow rules stack on top of broad defaults and defer downward with route.fallback().

Settlement is the other half of the contract. A route is a suspended request: until your callback calls a terminal method, the browser is still waiting. Forgetting the terminal call in one branch of an if is the most common interception bug, and it does not surface as an error — it surfaces as a navigation that never finishes and a test that dies on the action timeout, several seconds after the real mistake.

Settled versus unsettled route states A state diagram contrasting a route that receives exactly one terminal call with a route whose handler returns early and leaves the request pending until timeout. Route handler entered continue / fulfill / abort exactly one terminal call handler returns early no terminal call Route settled browser gets a response Request stays pending action times out Every branch of the handler needs its own terminal call or fallback().
Every path through the callback must end in a terminal call; an early return leaves the request suspended until the action timeout fires.

Troubleshooting variants

route.continue() with a new body has no effect

When you only need to change the request payload, pass postData to route.continue({ postData }); you cannot change the response from continue(). To alter the response you must switch to the route.fetch() + route.fulfill() pattern, because continue() hands control back to the network and never re-enters your handler. Confirm the rewrite landed by logging route.request().postData() for the matched request.

Modified response is rejected by the app as corrupt

Mismatched headers are the usual cause: if the real response was content-encoding: gzip and you replace the body with plain JSON, the browser tries to gunzip valid text and fails. When you override the body, drop or correct encoding and length headers — start from response for status but pass a clean contentType: 'application/json' and let Playwright recompute the length, rather than copying a stale content-length.

Aborting a request breaks an unrelated assertion

A glob that is too broad can abort an XHR the app needs to render. Narrow the pattern to the exact tracker host, and verify with page.on('requestfailed', r => console.log(r.url())) that only the intended URLs are cancelled. For traffic you want to silence but still observe in the Playwright Trace Viewer, prefer fulfilling an empty 204 over aborting so the request appears as handled rather than failed.

The handler fires for the preflight instead of the real call

A cross-origin POST or a request carrying a custom header triggers a CORS preflight, and that OPTIONS request matches the same URL glob as the call you meant to intercept. Reading route.request().postData() then returns null and the body edit silently does nothing. Guard on the method — if (route.request().method() === 'OPTIONS') return route.fallback(); — so the preflight passes through untouched and only the real verb reaches your editing branch. The same guard prevents a continue() override from stripping the access-control-request-headers value the browser needs to complete the handshake, which manifests as a request the app never retries.

The edited body reaches the page but the UI does not update

Interception happens below the application's data layer, so a client-side cache can serve the previous payload even after your handler returns fresh bytes. If a query cache or service worker already holds the key, the component never re-renders. Register the route before page.goto() rather than after the first navigation, and where a service worker is involved, disable it for the test run so requests actually reach the route handler instead of the worker's cache. Synchronizing on the rendered result instead of a fixed delay is covered in Handling Dynamic Content.

Verification

Prove each modification took effect. For header injection, run with --trace on and open the Network tab — the request row shows the x-tenant header you added, and the technique for reading those panels is detailed in reading network and console tabs in traces. For response edits, assert on the patched field in the rendered UI and confirm the unedited fields still reflect live data, which a pure mock could not produce. For aborts, check page.on('requestfailed') fires for the blocked URL and never for an application endpoint, then rerun with the abort removed to confirm the page still works — that difference isolates exactly what your rule changed.

Two further checks catch the mistakes that survive a green run. First, count the interceptions: increment a counter inside the handler and assert it equals the number of calls you expected, which exposes a glob that matched twice or a rule that never fired at all because it was registered after navigation. Second, pair the UI assertion with a direct API assertion using request.get() on the same endpoint, as described in asserting on API responses alongside UI; if the unmodified API answer and the modified UI disagree in exactly the field you patched and nowhere else, your edit is as narrow as you intended. For payloads that carry an operation name rather than a distinct path, the matching rules differ enough to warrant their own treatment in stubbing GraphQL requests.

Frequently Asked Questions

When should I modify a request instead of mocking it?

Modify when you need the real backend's data and only want to change one detail — inject a header, rewrite a parameter, or normalize a volatile field. Mock when you want to remove the dependency entirely and control the whole payload. Modification keeps integration coverage; mocking trades it for total determinism.

Can route.continue() change the response body?

No. route.continue() only rewrites the outgoing request (url, method, headers, postData) and then lets the real server answer. To change the response you must call route.fetch() to get the real response, edit it, and pass it to route.fulfill().

Does route.abort() make the test fail?

Not by itself. route.abort() cancels the network request and surfaces it as a failed request, which is exactly what you want for trackers and ads. The test only fails if the application code actually depended on that request, so scope the glob tightly to third-party noise.

What happens when two route handlers match the same URL?

Only one settles the request. Playwright consults page-level handlers before context-level ones and, within a level, the most recently registered handler first. If that handler calls a terminal method the others never run; if it calls route.fallback() the request continues down the chain to the next match, which is how a narrow per-spec override layers on top of a shared default.

Does route.fetch() count as a second request to my server?

It replaces the browser's request rather than adding to it. Playwright suspends the original request, performs the fetch from the test process using the same URL, method, and body unless you override them, and hands the result back. The server sees one round trip, though its client-side origin differs from a normal browser fetch, so cookies you rely on must be present in the context's storage state.

Back to overview