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 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, or abort, and pair trigger actions with waitForResponse() so nothing races.
The routing lifecycle
page.route(glob, handler) registers a handler on a single page; context.route() registers it for every page in the context. When a request matches, Playwright pauses it inside the browser and invokes your handler, which must terminate the route. The native implementation is faster and lighter than an external proxy, and because matching happens in the browser's network layer, there are no certificate or port concerns.
Matching uses glob patterns or a RegExp. Match on the path, not the full origin—prefix a glob with **/ so the same handler works across local, staging, and CI base URLs. Order matters: from Playwright v1.23 onward the most recently registered matching route wins, so a broad wildcard registered last shadows a specific pattern registered earlier. Register specific patterns after broad ones, or scope each carefully.
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();
});
Fulfill, continue, abort
The three terminators map to three 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.
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 continue() for branches you do not explicitly handle.
import { test, expect } from '@playwright/test';
test('fail only the POST, let reads through, 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' }),
});
} else {
await route.continue(); // fallback so the route always terminates
}
});
// Block non-essential traffic to speed the run.
await page.route('**/{analytics,fonts}/**', (route) => route.abort());
await page.goto('/cart');
await page.getByRole('button', { name: 'Place order' }).click();
await expect(page.getByRole('alert')).toContainText('try again');
});
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.
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();
});
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.
Isolation, teardown, and pitfalls
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. Watch for service workers: an aggressively caching worker can serve a response before your route sees the request, so register on the context and confirm the worker is not short-circuiting the fetch.
CORS preflight OPTIONS requests need their own handling or an explicit continue(), or the real request that follows will fail. 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.
Where interception connects across the suite
The requests you fulfill and continue here are exactly what the Playwright 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.