Network Interception Basics
Network control is the highest-leverage technique in browser automation because it severs the test from backend non-determinism. A test that hits a live API inherits every weakness of that API—variable latency, shifting seed data, rate limits, outages unrelated to the code under test. Playwright lets you pause any request inside the browser before it leaves, then decide its fate: answer it yourself, forward it (optionally rewritten), or block it. This guide, part of Advanced Interactions & Test Assertions, establishes the routing lifecycle, the matching and precedence rules, the response-wait discipline, and the isolation rules that keep handlers from leaking between tests.
Three rules prevent the large majority of interception bugs, and the whole guide returns to them: register handlers before the request fires, terminate every matched route with exactly one of fulfill, continue, fallback, or abort, and pair trigger actions with waitForResponse() so nothing races.
Where interception happens in the browser stack
Understanding where Playwright taps the request explains most of the surprising behaviour you will hit. Interception is implemented over the browser's own remote debugging protocol, at the point where the renderer has already decided to make a request but the network service has not yet dispatched it. That position has three consequences worth internalising before you write a handler.
First, interception is transparent to the application. The page's fetch() or XMLHttpRequest call does not know it was paused; it sees an ordinary response with ordinary headers. That is why you never need to shim window.fetch, and why a mocked response exercises the real parsing, error handling, and state-update code paths in the app. A fetch shim replaces the application's networking layer; a route handler replaces the network.
Second, interception sits below the page but above the transport, so there is no TLS negotiation, no proxy port, and no certificate to trust. A traditional man-in-the-middle proxy has to terminate TLS and re-sign certificates, which is why proxy-based mocking is brittle in CI and hostile to HTTP/2. Playwright's approach has none of those costs, and the handler runs in your Node process with full access to the test's variables, fixtures, and assertions.
Third, some traffic never reaches your handler at all. Requests that the browser answers from its HTTP cache, from a service worker, or from a preload scan may bypass routing entirely. Playwright disables the HTTP cache for routed requests in recent versions, but service workers remain the classic blind spot: an install-then-cache worker can respond from CacheStorage before the network layer is consulted. Register on the context rather than the page, or set serviceWorkers: 'block' in the context options, and the blind spot disappears.
Prerequisites
You need Playwright 1.30 or newer for the fallback semantics described below, a baseURL set in your config so globs can ignore the origin, and tests that already run against isolated contexts. If your suite shares a browser context across tests, fix that first with Browser Contexts & Isolation; route handlers registered on a shared context outlive the test that created them and produce failures that look random. Familiarity with fixtures helps too, because the cleanest way to ship a routing policy across a suite is a fixture, as covered in Playwright Config & Fixtures.
The routing lifecycle
page.route(glob, handler) registers a handler on a single page; context.route() registers it for every page in the context, including pages opened by target="_blank" links and popups. When a request matches, Playwright pauses it inside the browser and invokes your handler, which must terminate the route. The handler is async, so you can await a database call, read a fixture file, or compute a payload from test state before answering — the browser simply waits.
The handler receives a Route and, through route.request(), the full request metadata: URL, method, headers, post body, resource type, and whether the request is a navigation. route.request().resourceType() is the cheapest way to classify traffic without parsing URLs; it returns values like document, xhr, fetch, image, font, script, and stylesheet. Branching on resource type is how asset-blocking policies stay short.
Matching accepts a glob string, a RegExp, or a predicate function. Match on the path, not the full origin—prefix a glob with **/ so the same handler works across local, staging, and CI base URLs. A RegExp is matched against the whole URL, so anchor it loosely; a predicate function receives the parsed URL object and is the right tool when the decision depends on a query parameter.
import { test, expect } from '@playwright/test';
test('handler registered before navigation intercepts the first fetch', async ({ page }) => {
// Registering AFTER goto would miss the request that fires on load.
await page.route('**/api/profile', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ name: 'Test User', role: 'admin' }),
});
});
await page.goto('/account');
await expect(page.getByText('Test User')).toBeVisible();
});
Matching order, precedence, and fallback chains
Ordering is the rule most teams learn the hard way. From Playwright v1.23 onward the most recently registered matching route wins, so a broad wildcard registered last shadows a specific pattern registered earlier. This inversion is deliberate: it lets a test override a suite-wide policy installed by a fixture, because the test's own page.route() call runs later and therefore takes precedence.
That only works if the broad handler cooperates. route.fallback() hands the request down to the next matching handler in registration order instead of terminating it, which turns a set of handlers into a chain of middleware. A generic handler can inspect a request, decide it is not its concern, and call route.fallback() so a lower-priority handler gets a turn. route.continue() does not do this — it ends the chain and goes to the network. Confusing the two produces a handler that silently never runs.
You can also mutate a request on the way down the chain: route.fallback({ headers }) passes modified values to the next handler, letting you build layered policies where one handler adds a tenant header and another decides whether to mock.
import { test, expect } from '@playwright/test';
test('layered handlers: policy first, override last', async ({ page }) => {
// Registered FIRST, so it runs LAST in the precedence order.
await page.route('**/api/**', async (route) => {
// Generic policy: tag every API call, then defer to more specific handlers.
const headers = { ...route.request().headers(), 'x-test-run': 'ci' };
await route.fallback({ headers }); // hand down, do not terminate
});
// Registered SECOND, so it is consulted FIRST for matching requests.
await page.route('**/api/orders', async (route) => {
if (route.request().method() !== 'GET') {
await route.fallback(); // not our case; let the generic handler decide
return;
}
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify([{ id: 'A-1', total: 42 }]),
});
});
await page.goto('/orders');
await expect(page.getByRole('row', { name: /A-1/ })).toBeVisible();
});
Fulfill, continue, abort
The terminators map to distinct intentions. route.fulfill() answers the request entirely from your handler, so it never touches the network—this is how you make tests deterministic and is covered in depth in mocking API responses with Playwright. route.continue() forwards the request to the real server, optionally with rewritten headers, method, or post body—useful for injecting test headers or sanitizing payloads, and the subject of intercepting and modifying network requests. route.abort() blocks the request, which is the fastest way to drop analytics, fonts, and images that only slow CI; the economics of that are worked through in blocking images and fonts to speed up scraping.
A handler that matches a request but never calls one of these leaves the request hanging until it times out, which surfaces as a mysterious slow failure. Always provide a fallback branch for cases you do not explicitly handle.
Two options on fulfill() are worth knowing. path reads the body straight from a file on disk, which keeps large fixtures out of your spec files. response takes a Response object — typically one you fetched yourself — so you can retrieve the real payload, patch one field, and serve the result. That hybrid is the sweet spot for tests that need realistic data but one controlled anomaly.
import { test, expect } from '@playwright/test';
test('fail only the POST, patch the read, and drop assets', async ({ page }) => {
await page.route('**/api/checkout', async (route) => {
if (route.request().method() === 'POST') {
await route.fulfill({
status: 503,
contentType: 'application/json',
body: JSON.stringify({ error: 'Service Unavailable' }),
});
return;
}
// Fetch the genuine response, then override a single field.
const real = await route.fetch();
const json = await real.json();
json.currency = 'JPY'; // exercise the currency formatter deterministically
await route.fulfill({ response: real, json });
});
// Block non-essential traffic to speed the run; classify by resource type.
await page.route('**/*', async (route) => {
const type = route.request().resourceType();
if (type === 'image' || type === 'font') await route.abort();
else await route.fallback(); // let more specific handlers still run
});
await page.goto('/cart');
await page.getByRole('button', { name: 'Place order' }).click();
await expect(page.getByRole('alert')).toContainText('try again');
});
Note route.fetch() in that snippet: it performs the request from the Node process using the browser's cookies and headers, returning an APIResponse you can read and reshape. It is the bridge between interception and API testing, and it composes well with the assertion patterns in asserting on API responses alongside UI.
Waiting on responses without races
Modifying outbound requests is half the job; validating responses is the other. Register page.waitForResponse() before the action that triggers the request, then pair the wait and the trigger in Promise.all() so the listener is in place before anything fires. The predicate should match both URL and status so unrelated traffic does not satisfy it.
The failure mode is subtle because it is timing dependent. If you await the click first and then await page.waitForResponse(...), a fast response can arrive and be discarded before the listener exists, and the wait then hangs until the timeout. On a developer laptop against a slow staging API this passes every time; in CI against a warm local server it fails perhaps one run in twenty. That asymmetry is why the pattern is worth enforcing in review rather than debugging later — the same reasoning that drives the practices in Flaky Test Management.
import { test, expect } from '@playwright/test';
test('capture and assert the auth response payload', async ({ page }) => {
await page.goto('/login');
const [response] = await Promise.all([
page.waitForResponse((r) => r.url().includes('/auth/token') && r.status() === 200),
page.getByRole('button', { name: 'Sign in' }).click(),
]);
const payload = await response.json(); // parse inside a try/catch in real specs
expect(payload.accessToken).toBeDefined();
});
There is a related decision worth calling out: waitForResponse() waits for one specific exchange, whereas waitForLoadState('networkidle') waits for the whole page to go quiet. The first is precise and fast; the second is a blunt instrument that grows slower as an app adds background polling, and it never settles at all on pages with a websocket or a heartbeat. The trade-offs are compared in waiting for network idle vs element state.
This response discipline is what couples interception to the rest of a suite. When a form submit or a file upload depends on the network, the same wait pattern governs them in Form Automation & Input Handling and File Uploads & Downloads.
Simulating latency, failures, and offline conditions
Interception is not only about payloads. Because the handler controls when it terminates the route, it also controls the timing the application observes — which makes route handlers the practical way to test loading skeletons, spinners, optimistic UI rollbacks, and retry logic that is otherwise almost impossible to trigger on demand.
Delay is a setTimeout before fulfill(). Network-level failure is route.abort(errorCode), where the error code chooses the shape of the failure the browser reports: 'internetdisconnected' for offline, 'timedout' for a stalled connection, 'connectionrefused' for a dead service, 'connectionreset' for a mid-flight drop. Each surfaces differently to fetch() — a rejected promise rather than a resolved one with a 5xx status — so a UI that only handles response.ok === false will break on them, which is exactly the bug worth finding.
Application-level failure is a fulfill() with a 4xx or 5xx status and a realistic error body. Use both: transport failure and error-status failure exercise different branches.
import { test, expect } from '@playwright/test';
test('skeleton shows during a slow load, retry recovers from a dropped connection', async ({ page }) => {
let attempt = 0;
await page.route('**/api/dashboard', async (route) => {
attempt += 1;
if (attempt === 1) {
// First call: kill the transport so the app sees a rejected fetch.
await route.abort('connectionreset');
return;
}
// Second call: succeed, but slowly, so the skeleton is observable.
await new Promise((resolve) => setTimeout(resolve, 1500));
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ widgets: [{ id: 'w1', label: 'Revenue' }] }),
});
});
await page.goto('/dashboard');
// The error state must appear before the automatic retry kicks in.
await expect(page.getByRole('alert')).toContainText('Connection lost');
await page.getByRole('button', { name: 'Retry' }).click();
// The skeleton is visible during the artificial 1.5s delay.
await expect(page.getByTestId('dashboard-skeleton')).toBeVisible();
await expect(page.getByText('Revenue')).toBeVisible();
expect(attempt).toBe(2); // proves the retry actually re-requested
});
Keep injected delays short. A 1500 ms pause is enough to observe a skeleton and cheap enough to run on every commit; multi-second delays multiplied across a suite turn into minutes of wall clock for no extra signal. Also keep the delay well inside the test's expect timeout, or you will be debugging your own fixture rather than the app.
Isolation, teardown, and handler hygiene
Routes are scoped to the page or context they were registered on, and each test gets a fresh context by default, so handlers do not leak between tests or parallel workers. Within a single test, remove a handler mid-run with page.unroute() when a later step must reach the live service, and pass the same handler reference you registered — unroute() with only a URL removes every handler for that pattern, which is usually what you want but occasionally more than you intended. page.unrouteAll({ behavior: 'ignoreErrors' }) clears everything and is the safe call in an afterEach when handlers were installed on a worker-scoped context.
Two hygiene rules matter more than the API details. First, keep handler bodies synchronous in spirit: a handler that awaits a long operation holds the browser's request open, and if the test times out mid-handler you get a confusing error about a closed page rather than about your own code. Second, never assert inside a route handler. An expect() failure thrown in a handler surfaces as an unhandled rejection at an unpredictable point in the run; instead record what you saw in a local variable and assert after the action completes, as the retry-count check does above.
CORS preflight OPTIONS requests need their own handling or an explicit continue(), or the real request that follows will fail. When you fulfill a cross-origin request yourself, remember to include access-control-allow-origin in the headers you return, because your mock replaces the server's CORS headers too — a fulfilled response with no CORS headers is rejected by the browser exactly as a misconfigured server would be.
And when you mock, never re-assert the payload you just wrote—assert the rendered effect instead, so the test proves behavior rather than that your mock equals itself. Authentication is the one area where interception is usually the wrong tool: stubbing the login endpoint bypasses the very session handling most bugs live in, so persist real credentials once instead, as in reusing login state with storageState.
Failure modes and debugging
When a handler misbehaves, work through the causes in order of likelihood rather than guessing. The glob is wrong far more often than Playwright is. Attach page.on('request', (r) => console.log(r.method(), r.url())) for one run and read the actual URLs, query strings included; a pattern like **/api/orders will not match /api/orders?page=2 unless you end it with ** or drop the trailing segment anchor.
Registration timing is the second cause. Anything that fires during navigation — the document request itself, a preloaded script, an app-shell fetch — is already in flight before a page.route() call placed after goto(). The third is a service worker or the back/forward cache answering from storage. The fourth is another handler shadowing yours, which the fallback chain section covers.
The confirmation step is not optional. Run with trace: 'on' once and open the Network panel of the Playwright Trace Viewer; a fulfilled request shows your synthetic status and body, a continued one shows the origin's. Reading that panel well is a skill of its own, walked through in reading network and console tabs in traces.
CI/CD considerations
Interception changes the economics of a pipeline. Aborting images, fonts, and third-party analytics typically removes a third of the requests on a content-heavy page, and on shared CI runners that is often the difference between a six-minute and a four-minute suite. Install that policy once in a fixture so every test inherits it and no spec has to remember.
Be deliberate about which suites mock and which do not. A fully mocked suite is fast and stable but proves nothing about the backend contract; a fully live suite proves the contract but inherits every outage. The usual split is a large mocked suite gating every pull request and a smaller contract suite running against a real environment on a schedule. Tag them and select with --grep, so the same specs can run in either mode where that makes sense.
Finally, guard against silent drift. A mock that no longer resembles the real response keeps passing while production breaks — the single biggest risk of heavy interception. Generate mocks from real traffic where you can, version them next to the specs, and refresh them on a cadence. Recording is the mechanism that makes this practical rather than aspirational.
Deep dives beneath this guide
Four pages take the material here further, each on a specific slice of the routing API.
- Mocking API Responses with Playwright — how to shape
fulfill()payloads, status codes, and headers so a test is deterministic without becoming a tautology. - Intercepting and Modifying Network Requests — rewriting headers, methods, and post bodies in flight with
continue()while the request still reaches the real server. - Stubbing GraphQL Requests in Playwright — matching a single operation by name out of a stream of POSTs that all share one endpoint URL, which glob patterns alone cannot do.
- Recording and Replaying HAR Files — capturing a whole session's traffic once with
routeFromHAR()and replaying it offline, instead of hand-writing every mock.
Where interception connects across the suite
The requests you fulfill and continue here are exactly what the Trace Viewer replays in its Network panel; when an interception test misbehaves, the trace shows whether a request was served from the handler or the server, making the cause obvious. The same route() machinery is also the backbone of Web Scraping & Data Extraction, where you capture or replay responses to pull structured data and to stay within rate limits. Master the routing lifecycle once and it pays off in debugging and data work alike.
Frequently Asked Questions
Why does my route handler never fire?
Either the glob did not match or the handler was registered after the request fired. Log requests with page.on('request', r => console.log(r.url())) to confirm the exact URL including query strings, prefix the glob with **/ to ignore the origin, and make sure page.route() runs before page.goto() or the triggering action.
What is the difference between fulfill, continue, and abort?
route.fulfill() answers the request from your handler so it never reaches the network; route.continue() forwards it to the real server, optionally with rewritten headers, method, or body; route.abort() blocks it entirely. Every matched route must call exactly one of them, or the request hangs until timeout.
Do route handlers leak between tests?
No. Routes are scoped to the page or context they were registered on, and each test gets a fresh context by default, so handlers do not carry over to other tests or parallel workers. Use page.unroute() to remove a handler within a test when a later step needs the real service.
When should I use route.fallback() instead of route.continue()?
Use fallback() when another handler should get a chance at the request, and continue() when the request should go to the network now. A generic policy handler that tags requests and then defers to specific handlers must call fallback(); calling continue() there ends the chain and the specific handlers never run.
Which handler wins when two patterns match the same URL?
The most recently registered one. Playwright walks matching handlers newest first, which lets a test override a policy installed earlier by a fixture. If the newer handler calls fallback(), the next-newest gets its turn, and so on down to the network.
Can I simulate a slow or offline network with page.route()?
Yes. Await a timer inside the handler before terminating the route to inject latency, and call route.abort('internetdisconnected') or route.abort('connectionreset') to produce a transport-level failure rather than an error status. Those two failure shapes reach the application differently, so cover both when testing error handling.
Does interception work for websockets and server-sent events?
page.route() covers HTTP requests, including the initial handshake, but not the frames of an established websocket — Playwright exposes those through page.routeWebSocket() and the websocket event instead. Server-sent events arrive over an ordinary HTTP response, so you can fulfill one with an event-stream body if you set the content type correctly.