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.
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.
Step-by-step fix
- 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. - Rewrite the outgoing request with
route.continue(). Pass{ headers, method, postData, url }to change what leaves the browser while still hitting the real server. Spreadroute.request().headers()first so you only override the keys you mean to. - Edit a real response with
route.fetch()thenroute.fulfill(). Callroute.fetch()to perform the genuine request, read its body, mutate the fields you need, thenfulfill({ response, body })so the status and headers stay authentic and only the body changes. - Abort noise with
route.abort(). Cancel analytics, ads, fonts, or third-party beacons by matching their URL and callingroute.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. - 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. - Always settle the route. Each handler must call exactly one of
continue,fulfill,fetch+fulfill, orabort. 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();
});
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.
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.