Network interception is one of Playwright's most powerful features — and one of the most underused. Most teams either hit real APIs in every test (slow, flaky, environment-dependent) or skip network testing entirely. Neither approach works at scale.
With page.route(), you can intercept any HTTP request, return custom responses, modify real API data on the fly, simulate failures, and even record/replay entire sessions from HAR files. The result: tests that are fast, deterministic, and completely isolated from external dependencies. This guide walks through every pattern you need, from basic mocking to advanced interception strategies used in production end-to-end test suites.
Why Intercept Network Requests?
Every UI test that hits a real API is at the mercy of external factors: server uptime, database state, network latency, rate limits, and third-party service availability. Network interception eliminates all of these variables.
- Test isolation — each test runs against predictable, controlled data. No shared database state, no cleanup scripts, no order-dependent test suites
- Speed — mocked responses return in microseconds, not milliseconds. A test suite that takes 5 minutes with real APIs can drop to 30 seconds with mocks
- Edge case coverage — test 500 errors, empty lists, paginated results, malformed JSON, timeouts, and network failures without configuring your backend to produce them
- No external dependencies — run tests offline, in CI without VPN access, or against services that don't have staging environments
- Parallel safety — mocked tests don't share server state, so they can run in parallel without conflicts or race conditions
When to use real APIs vs. mocks: Use real APIs in a small smoke suite that validates your backend integration. Use mocks everywhere else — error states, loading behavior, UI rendering with various data shapes, and any test that doesn't specifically need to verify the API itself.
page.route() — The Core API
The page.route() method is the foundation of all network interception in Playwright. It registers a handler that intercepts requests matching a URL pattern before they leave the browser.
Basic Syntax
await page.route(urlPattern, handlerFunction); // urlPattern can be: // 1. String with glob: '**/api/users*' // 2. RegExp: /\/api\/users\/\d+/ // 3. Function: (url) => url.pathname.startsWith('/api')
Glob Pattern Matching
Glob patterns are the most common way to match URLs. Playwright uses ** to match any path prefix and * to match any characters within a segment.
// Match any request to /api/users (any origin) await page.route('**/api/users', handler); // Match /api/users with any query params await page.route('**/api/users?**', handler); // Match any API endpoint await page.route('**/api/**', handler); // Match specific origin + path await page.route('https://api.example.com/v2/users', handler);
RegExp Matching
For more precise control, use regular expressions. This is useful when you need to match dynamic path segments or complex URL structures.
// Match /api/users/123 (numeric ID) await page.route(/\/api\/users\/\d+$/, handler); // Match any v1 or v2 API call await page.route(/\/api\/v[12]\//, handler); // Match image requests (png, jpg, webp) await page.route(/\.(png|jpe?g|webp)$/, handler);
Route Handler Actions
Inside a route handler, you have three options:
await page.route('**/api/data', async (route) => { // Option 1: Fulfill — return a custom response await route.fulfill({ body: '{"mock": true}' }); // Option 2: Continue — let the request proceed to the server await route.continue(); // Option 3: Abort — block the request entirely await route.abort(); });
Important: Every route handler must call exactly one of route.fulfill(), route.continue(), or route.abort(). If you forget, the request hangs indefinitely and your test times out. This is the most common mistake with network interception.
Mocking API Responses
The most common interception pattern is returning a completely custom response. Use route.fulfill() to send back any status code, headers, and body you want.
Return Custom JSON
test('displays user list from mocked API', async ({ page }) => { const mockUsers = [ { id: 1, name: 'Alice Chen', role: 'admin' }, { id: 2, name: 'Bob Smith', role: 'editor' }, { id: 3, name: 'Carol Davis', role: 'viewer' }, ]; await page.route('**/api/users', async (route) => { await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ users: mockUsers, total: 3 }), }); }); await page.goto('/dashboard/users'); await expect(page.getByText('Alice Chen')).toBeVisible(); await expect(page.getByText('Bob Smith')).toBeVisible(); await expect(page.getByText('3 users')).toBeVisible(); });
Mock Empty States
test('shows empty state when no results exist', async ({ page }) => { await page.route('**/api/orders*', async (route) => { await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ orders: [], total: 0, page: 1 }), }); }); await page.goto('/orders'); await expect(page.getByText('No orders found')).toBeVisible(); });
Mock with Custom Headers
await page.route('**/api/auth/token', async (route) => { await route.fulfill({ status: 200, headers: { 'Content-Type': 'application/json', 'X-RateLimit-Remaining': '99', 'X-Request-Id': 'mock-req-001', }, body: JSON.stringify({ access_token: 'mock-jwt-token-xyz', expires_in: 3600, }), }); });
Modifying Real Responses
Sometimes you need real API data but want to tweak part of it — add a field, change a value, or inject test-specific flags. The pattern is: intercept, fetch the real response, modify it, then fulfill.
test('injects feature flag into real config response', async ({ page }) => { await page.route('**/api/config', async (route) => { // Fetch the real response from the server const response = await route.fetch(); const body = await response.json(); // Modify it: enable a feature flag that's off in staging body.featureFlags.newCheckoutFlow = true; body.featureFlags.darkMode = true; // Return the modified response await route.fulfill({ response, // preserve original status + headers body: JSON.stringify(body), }); }); await page.goto('/settings'); await expect(page.getByText('New Checkout Flow')).toBeVisible(); });
Modify Response Headers
await page.route('**/api/**', async (route) => { const response = await route.fetch(); const headers = response.headers(); // Override CORS headers for local testing headers['access-control-allow-origin'] = '*'; await route.fulfill({ response, headers, }); });
Passing response to fulfill: When you include the original response object in route.fulfill(), Playwright preserves the original status code and headers. Any fields you explicitly set (like body or headers) override the originals. This is cleaner than manually reconstructing the entire response.
Simulating Error Scenarios
Your UI should handle errors gracefully. Network interception lets you test every failure mode without breaking your backend or waiting for real outages.
500 Internal Server Error
test('shows error banner on 500 response', async ({ page }) => { await page.route('**/api/dashboard', async (route) => { await route.fulfill({ status: 500, contentType: 'application/json', body: JSON.stringify({ error: 'Internal Server Error', message: 'Database connection failed', }), }); }); await page.goto('/dashboard'); await expect(page.getByRole('alert')).toContainText('Something went wrong'); });
401 Unauthorized (Session Expired)
test('redirects to login on 401', async ({ page }) => { await page.route('**/api/**', async (route) => { await route.fulfill({ status: 401, contentType: 'application/json', body: JSON.stringify({ error: 'Token expired' }), }); }); await page.goto('/profile'); await expect(page).toHaveURL(/\/login/); });
Network Failure (Abort)
test('shows offline message when network fails', async ({ page }) => { await page.route('**/api/**', async (route) => { await route.abort('failed'); }); await page.goto('/dashboard'); await expect( page.getByText('Unable to connect') ).toBeVisible(); }); // Abort reasons: 'aborted', 'accessdenied', 'addressunreachable', // 'blockedbyclient', 'connectionaborted', 'connectionclosed', // 'connectionfailed', 'connectionrefused', 'connectionreset', // 'internetdisconnected', 'namenotresolved', 'timedout', 'failed'
Abort vs. error status: route.abort() simulates a network-level failure (DNS error, connection refused, etc.). route.fulfill({ status: 500 }) simulates a server that responds with an error. Your app handles these differently — test both.
Simulating Slow Networks
Loading spinners, skeleton screens, and timeout handling all depend on network speed. Use delayed fulfillment to test these states reliably.
Delay a Single Endpoint
test('shows loading skeleton during slow API call', async ({ page }) => { await page.route('**/api/products', async (route) => { // Delay 3 seconds before responding await new Promise((r) => setTimeout(r, 3000)); await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ products: [{ id: 1, name: 'Widget' }] }), }); }); await page.goto('/products'); // Immediately check for loading state await expect(page.getByTestId('skeleton-loader')).toBeVisible(); // Wait for data to arrive await expect(page.getByText('Widget')).toBeVisible(); // Skeleton should be gone await expect(page.getByTestId('skeleton-loader')).toBeHidden(); });
Throttle All Network Traffic
// Add a delay to every API request async function throttleNetwork(page: Page, delayMs: number) { await page.route('**/api/**', async (route) => { await new Promise((r) => setTimeout(r, delayMs)); await route.continue(); }); } test('app handles slow network gracefully', async ({ page }) => { await throttleNetwork(page, 2000); await page.goto('/dashboard'); // Assertions for loading states, progress indicators, etc. });
Waiting for Network Events
Sometimes you need to assert that a specific API call was made — for example, verifying that a form submission sends the correct payload, or that a button click triggers the right API request. Playwright's waitForRequest and waitForResponse are built for this.
Assert a Request Was Made
test('submit button sends correct payload', async ({ page }) => { await page.goto('/contact'); // Start waiting BEFORE the action that triggers the request const requestPromise = page.waitForRequest('**/api/contact'); await page.getByLabel('Email').fill('test@example.com'); await page.getByLabel('Message').fill('Hello from Playwright'); await page.getByRole('button', { name: 'Send' }).click(); const request = await requestPromise; const payload = request.postDataJSON(); expect(payload.email).toBe('test@example.com'); expect(payload.message).toBe('Hello from Playwright'); });
Assert a Response Status
test('delete action returns 204', async ({ page }) => { await page.goto('/admin/users'); const responsePromise = page.waitForResponse( (resp) => resp.url().includes('/api/users/') && resp.status() === 204 ); await page.getByRole('button', { name: 'Delete' }).first().click(); await page.getByRole('button', { name: 'Confirm' }).click(); const response = await responsePromise; expect(response.status()).toBe(204); });
Timing matters: Always call waitForRequest or waitForResponse before the action that triggers the network call. If you call it after, the request may have already completed and the promise will never resolve. This follows Playwright's auto-waiting pattern.
Recording and Replaying Network Traffic
HAR (HTTP Archive) files capture every network request and response from a real session. Playwright can record HAR files during test runs, then replay them later so tests run against saved data instead of live servers.
Record a HAR File
test('record network traffic', async ({ browser }) => { const context = await browser.newContext({ recordHar: { path: './tests/fixtures/dashboard.har', urlFilter: '**/api/**', // only record API calls }, }); const page = await context.newPage(); await page.goto('/dashboard'); await page.getByRole('button', { name: 'Load More' }).click(); await page.waitForLoadState('networkidle'); // Save the HAR file await context.close(); });
Replay a HAR File
test('replay dashboard from saved HAR', async ({ page }) => { // Replay all API responses from the HAR file await page.routeFromHAR('./tests/fixtures/dashboard.har', { url: '**/api/**', update: false, // don't re-record, just replay }); await page.goto('/dashboard'); // These assertions pass using saved data, no server needed await expect(page.getByText('Revenue: $45,230')).toBeVisible(); await expect(page.getByText('Orders: 1,247')).toBeVisible(); });
Auto-Update HAR on Demand
// Set update: true to re-record when HAR is stale await page.routeFromHAR('./tests/fixtures/search.har', { url: '**/api/search**', update: true, // hits real server and updates the HAR file });
HAR workflow: Record once with update: true, commit the HAR file to your repo, then switch to update: false for daily CI runs. When your API changes, flip back to update: true for one run to refresh the snapshots. This gives you the speed of mocks with the accuracy of real data.
Request Interception Patterns
Beyond basic mocking, network interception enables several powerful patterns for testing complex workflows without touching your backend infrastructure.
Login Bypass
Skip the login flow entirely by mocking the auth endpoint:
test.beforeEach(async ({ page }) => { // Mock the auth check to always return authenticated await page.route('**/api/auth/me', async (route) => { await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ id: 1, email: 'test@example.com', role: 'admin', name: 'Test Admin', }), }); }); });
Feature Flag Testing
async function enableFeatureFlags( page: Page, flags: Record<string, boolean> ) { await page.route('**/api/feature-flags', async (route) => { const response = await route.fetch(); const body = await response.json(); await route.fulfill({ response, body: JSON.stringify({ ...body, ...flags }), }); }); } test('new pricing page behind feature flag', async ({ page }) => { await enableFeatureFlags(page, { newPricingPage: true }); await page.goto('/pricing'); await expect(page.getByText('Choose your plan')).toBeVisible(); });
Payment Gateway Mocking
test('successful checkout with mocked Stripe', async ({ page }) => { // Mock Stripe's payment intent creation await page.route('**/api/payments/create-intent', async (route) => { await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ clientSecret: 'pi_mock_secret_123', id: 'pi_mock_123', }), }); }); // Mock payment confirmation await page.route('**/api/payments/confirm', async (route) => { await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ status: 'succeeded', orderId: 'ORD-2026-0815', }), }); }); await page.goto('/checkout'); await page.getByRole('button', { name: 'Pay Now' }).click(); await expect(page.getByText('Order confirmed')).toBeVisible(); });
Network Interception in API Testing
Playwright's APIRequestContext (the request fixture) is for direct HTTP calls without a browser. But you can combine it with page.route() for hybrid tests that seed data via API, then verify the UI with mocked downstream calls.
test('API-seeded product renders correctly with mocked reviews', async ({ request, page, }) => { // Step 1: Create real product via API const res = await request.post('/api/products', { data: { name: 'Network Test Widget', price: 29.99 }, }); const { id } = await res.json(); // Step 2: Mock the reviews endpoint (3rd-party service) await page.route(`**/api/products/${id}/reviews`, async (route) => { await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ reviews: [ { rating: 5, text: 'Great product!' }, { rating: 4, text: 'Very useful.' }, ], avgRating: 4.5, }), }); }); // Step 3: Verify the UI await page.goto(`/products/${id}`); await expect(page.getByText('Network Test Widget')).toBeVisible(); await expect(page.getByText('$29.99')).toBeVisible(); await expect(page.getByText('4.5')).toBeVisible(); // Cleanup await request.delete(`/api/products/${id}`); });
This hybrid approach gives you the best of both worlds: real data where it matters (the product from your own API), mocked data where external services are unreliable (third-party reviews). For a deeper dive into APIRequestContext, see our Playwright API testing guide.
Common Mistakes and Fixes
Network interception has subtle gotchas that cause tests to hang, pass incorrectly, or miss real bugs. Here are the most common issues and how to solve them.
Route Not Matching
Pattern '/api/users' does not match https://app.com/api/users because it lacks the glob prefix.
Use '**/api/users' with the ** prefix to match any origin, or provide the full URL.
Forgetting to Fulfill
// BUG: This route handler never resolves the request! await page.route('**/api/data', async (route) => { console.log('Intercepted!'); // Forgot route.fulfill(), route.continue(), or route.abort() // The request hangs forever → test timeout }); // FIX: Always call exactly one action await page.route('**/api/data', async (route) => { console.log('Intercepted!'); await route.continue(); // let it pass through });
Race Condition: Route After Navigation
Calling page.goto() before page.route(). The request fires during navigation before your handler is registered.
Always register page.route() before any navigation or action that triggers the request you want to intercept.
Route Persisting Across Tests
// Remove a specific route handler await page.unroute('**/api/users'); // Or remove all routes matching a pattern await page.unroute('**/api/**');
Each Playwright test gets a fresh page by default, so routes don't leak between tests. But if you share a page across tests (not recommended), or register routes at the browser context level with context.route(), you need to clean them up manually. Follow Playwright best practices and keep tests isolated.
Generate Network Mocks with Claude AI
Writing mock data by hand is tedious, especially for complex APIs with nested objects, arrays, and realistic values. Claude AI can generate complete mock payloads and route handlers from a simple description of your API.
What Claude AI Generates For You
- Realistic mock data — proper names, emails, UUIDs, timestamps, and edge-case values instead of "test123" placeholders
- Complete route handlers — full
page.route()setup with proper content types, status codes, and response shapes matching your API - Error scenario mocks — handlers for 400, 401, 403, 404, 422, 429, and 500 responses with correct error body structures
- HAR file generation — create synthetic HAR files for
routeFromHAR()without recording from a live server - Mock factory functions — reusable TypeScript functions that generate mock data with randomized but valid values
In the Playwright + Claude AI & MCP Server course, you learn how to connect Claude directly to your codebase via MCP Server, so it can read your API types, generate matching mocks, and insert them into your test files automatically. The AI understands your schema, your endpoint patterns, and your testing conventions.
Frequently Asked Questions
What is page.route() in Playwright?
page.route() is Playwright's network interception API. It intercepts HTTP requests matching a URL pattern and lets you fulfill them with custom responses, modify the real response, or abort the request entirely. It works for all resource types — XHR, fetch, images, scripts, and stylesheets.
How do I mock an API response in Playwright?
Use page.route() with route.fulfill(). Register the route before navigating: await page.route('**/api/endpoint', async (route) => { await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ data: 'mocked' }) }); });. The handler intercepts the request and returns your custom response instead of hitting the real server.
Can Playwright intercept and modify real API responses?
Yes. Use route.fetch() inside your handler to send the request to the real server, then modify the response body or headers before passing it to the browser with route.fulfill({ response, body: modifiedBody }). This is useful for injecting feature flags, test data, or overriding specific fields without faking the entire response.
What is HAR file replay in Playwright?
HAR (HTTP Archive) replay records all network traffic from a real session into a .har file, then replays those saved responses in future test runs via page.routeFromHAR(). This makes tests completely deterministic and independent of external services. Record once, replay everywhere — in CI, offline, or across environments.
How do I simulate network errors and timeouts in Playwright?
For network-level errors (DNS failure, connection refused), use route.abort('failed') or route.abort('connectionrefused'). For timeouts, add a setTimeout delay before route.fulfill(). For HTTP errors, use route.fulfill({ status: 500 }). Test all three — your app should handle each case differently.
Asim Noaman
Senior QA Automation Engineer & AI Testing Specialist
With years of hands-on experience building test automation frameworks for production applications, Asim specializes in combining traditional QA methodologies with cutting-edge AI tools. He has helped teams adopt Playwright and AI-driven testing workflows to ship faster with fewer bugs.