The Playwright Playbook β Part 2: Network Interception β The Complete Guide
"Your tests should own the network β not be at its mercy."
In Part 1, we built the full foundation β semantic locators, storageState, Page Object Model with LoginPage and TaskPage (including deleteTask), an auth fixture, and a clean playwright.config.ts. If you haven't read that, start there. Every file we reference today was built in Part 1.
Now we go to the feature that separates intermediate Playwright users from advanced ones.
Network interception.
Most people know page.route() exists. Most people use it once, mock one response, and move on.
But network interception is an entire testing strategy β not just a utility. When you fully understand it, you can:
- Test your frontend completely independently of the backend
- Simulate error states, timeouts, and edge cases in seconds
- Assert on exactly what API calls your app is making β and what it's sending
- Record real network traffic and replay it in tests without a live server
- Catch silent API failures that your UI swallows gracefully
This is the part where Playwright stops feeling like a UI testing tool and starts feeling like a full testing platform. π
Let's build it. π―
ποΈ Where We Left Off
At the end of Part 1, our project was fully built:
playwright-playbook/
βββ tests/
β βββ auth/
β β βββ login.spec.ts
β βββ tasks/
β βββ task-management.spec.ts
βββ pages/
β βββ LoginPage.ts
β βββ TaskPage.ts β includes createTask + deleteTask
βββ fixtures/
β βββ auth.fixture.ts
βββ .auth/
β βββ admin.json
β βββ user.json
βββ global-setup.ts
βββ playwright.config.ts
βββ .env
βββ package.json
By the end of Part 2, we add a network/ test folder, a scripts/ folder, and expand fixtures/ with mock data and HAR files:
playwright-playbook/
βββ tests/
β βββ auth/
β βββ tasks/
β βββ network/ β NEW
β βββ api-mocking.spec.ts
β βββ error-simulation.spec.ts
β βββ network-assertions.spec.ts
βββ pages/
βββ fixtures/
β βββ auth.fixture.ts
β βββ tasks.json β NEW
β βββ empty-tasks.json β NEW
β βββ tasks-har.har β NEW (generated, not hand-written)
βββ scripts/ β NEW
β βββ record-har.ts
βββ .auth/
βββ global-setup.ts
βββ playwright.config.ts
βββ .env
π How Playwright Network Interception Works
Before we write code β let's understand the architecture.
Every time your browser makes a network request, Playwright can intercept it at the network layer β before it ever reaches the server.
Browser Action (click "Load Tasks")
β
βΌ
Playwright Route Handler β you control this
β
ββββββ΄βββββββββ
β β β
βΌ βΌ βΌ
Fulfill Abort Continue
(mock) (kill it) (real server,
optionally modified)
Three things you can do with an intercepted request:
- Fulfill β return a fake response you define (mock)
- Abort β kill the request entirely (simulate network failure)
- Continue β let it go through to the real server, optionally with modified headers or body
That's the whole mental model. Everything we do in this part is a variation of those three options. Let's build each one. π
π§ͺ The Basic: page.route() β Your First Mock
Our Task Manager app calls GET /api/tasks to load the task list. In most test setups, this requires a running backend with seeded data.
With network interception β we don't need that.
First, create your mock data files:
// fixtures/tasks.json
[
{ "id": 1, "title": "Write unit tests", "status": "pending", "assignee": "admin" },
{ "id": 2, "title": "Review pull request", "status": "completed", "assignee": "user" },
{ "id": 3, "title": "Fix flaky test in CI", "status": "pending", "assignee": "admin" }
]
// fixtures/empty-tasks.json
[]
Now intercept and mock the API call in your test:
// tests/network/api-mocking.spec.ts
import { test, expect } from '@playwright/test';
import tasks from '../../fixtures/tasks.json';
import emptyTasks from '../../fixtures/empty-tasks.json';
test('task list renders mocked data correctly', async ({ page }) => {
// β οΈ Set up route BEFORE page.goto() β the handler must exist before requests fire
await page.route('**/api/tasks', async route => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(tasks),
});
});
await page.goto('/tasks');
// Assert the UI rendered the mocked data
await expect(page.getByRole('listitem')).toHaveCount(3);
await expect(page.getByText('Write unit tests')).toBeVisible();
await expect(page.getByText('Review pull request')).toBeVisible();
});
test('shows empty state when no tasks exist', async ({ page }) => {
await page.route('**/api/tasks', async route => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(emptyTasks),
});
});
await page.goto('/tasks');
await expect(page.getByTestId('empty-state')).toBeVisible();
await expect(page.getByText('No tasks yet')).toBeVisible();
await expect(page.getByRole('listitem')).toHaveCount(0);
});
Key rule: Always set up page.route() BEFORE page.goto(). The route handler needs to be registered before the page makes any requests. Miss this and your mock silently does nothing. π―
β Simulating Error States
This is where network interception becomes genuinely powerful.
How do you test what happens when your API returns a 500? Or a 401? Or a timeout?
Without interception β you need to break your backend. With interception β one line.
// tests/network/error-simulation.spec.ts
import { test, expect } from '@playwright/test';
test('shows error banner when API returns 500', async ({ page }) => {
await page.route('**/api/tasks', async route => {
await route.fulfill({
status: 500,
contentType: 'application/json',
body: JSON.stringify({ error: 'Internal Server Error' }),
});
});
await page.goto('/tasks');
await expect(page.getByTestId('error-banner')).toBeVisible();
await expect(page.getByText('Something went wrong')).toBeVisible();
// Task list should NOT render when the API fails
await expect(page.getByTestId('task-list')).not.toBeVisible();
});
test('shows empty state when API returns 404', async ({ page }) => {
await page.route('**/api/tasks', async route => {
await route.fulfill({
status: 404,
contentType: 'application/json',
body: JSON.stringify({ error: 'Not found' }),
});
});
await page.goto('/tasks');
await expect(page.getByTestId('empty-state')).toBeVisible();
await expect(page.getByText('No tasks found')).toBeVisible();
});
test('shows loading skeleton when API is slow', async ({ page }) => {
await page.route('**/api/tasks', async route => {
// Delay the response β simulates a slow network or overloaded server
await new Promise(resolve => setTimeout(resolve, 3000));
await route.continue();
});
await page.goto('/tasks');
// Skeleton should be visible WHILE the request is still in flight
await expect(page.getByTestId('loading-skeleton')).toBeVisible();
});
test('shows retry button when network fails completely', async ({ page }) => {
await page.route('**/api/tasks', async route => {
// Abort kills the request entirely β simulates no internet
await route.abort('failed');
});
await page.goto('/tasks');
await expect(page.getByTestId('network-error')).toBeVisible();
await expect(page.getByRole('button', { name: 'Retry' })).toBeVisible();
});
Think about what you just did. You tested four different failure scenarios β without touching a single line of backend code. Without spinning up a special error environment. Without asking a developer to temporarily break something. πͺ
π Modifying Requests on the Fly
Sometimes you don't want to fully mock a response. You want to intercept a real request, tweak it, and let it continue to the actual server.
// Inject custom test headers into every API call
test('injects test mode headers into requests', async ({ page }) => {
await page.route('**/api/**', async route => {
const headers = {
...route.request().headers(),
'x-test-mode': 'true',
'x-test-session': 'automation',
};
await route.continue({ headers });
});
await page.goto('/tasks');
// Request goes to real server β but now carries test headers
// Useful when your backend logs or behaves differently in test mode
});
// Modify query params before the request leaves the browser
test('filters to completed tasks via modified query param', async ({ page }) => {
await page.route('**/api/tasks', async route => {
const url = new URL(route.request().url());
url.searchParams.set('status', 'completed');
await route.continue({ url: url.toString() });
});
await page.goto('/tasks');
// Only completed tasks show β filtered at the network layer
await expect(page.getByText('Review pull request')).toBeVisible();
await expect(page.getByText('Write unit tests')).not.toBeVisible();
});
π‘ Asserting on Real Network Calls β waitForResponse
Everything so far has been about controlling the network. Now let's talk about observing it.
This is the pattern I use most in API-heavy applications β asserting that the correct API calls are made, with the correct payloads, at exactly the right time.
β οΈ Critical pattern: page.waitForResponse() must be set up BEFORE the action that triggers the request. If you await it first and THEN click the button β you'll miss the response. The correct pattern is Promise.all.
// tests/network/network-assertions.spec.ts
import { test, expect } from '@playwright/test';
import { TaskPage } from '../../pages/TaskPage';
test('creating a task fires a POST to /api/tasks', async ({ page }) => {
const taskPage = new TaskPage(page);
await taskPage.goto();
// β
Set up the listener and trigger the action AT THE SAME TIME
const [response] = await Promise.all([
page.waitForResponse(
resp =>
resp.url().includes('/api/tasks') &&
resp.request().method() === 'POST' &&
resp.status() === 201
),
taskPage.createTask('Write integration tests'),
]);
// Assert on the actual API response
const body = await response.json();
expect(body.title).toBe('Write integration tests');
expect(body.status).toBe('pending');
expect(body.id).toBeDefined();
// Assert the UI also updated
await expect(taskPage.getTaskLocator('Write integration tests')).toBeVisible();
});
test('deleting a task fires DELETE with the correct task ID', async ({ page }) => {
const taskPage = new TaskPage(page);
await taskPage.goto();
// Create a task first and capture its ID from the API response
const [createResponse] = await Promise.all([
page.waitForResponse(
resp => resp.url().includes('/api/tasks') && resp.request().method() === 'POST'
),
taskPage.createTask('Task to be deleted'),
]);
const { id } = await createResponse.json();
// Now watch for the DELETE call on that specific ID
const [deleteResponse] = await Promise.all([
page.waitForResponse(
resp =>
resp.url().includes(`/api/tasks/${id}`) &&
resp.request().method() === 'DELETE'
),
taskPage.deleteTask('Task to be deleted'),
]);
expect(deleteResponse.status()).toBe(200);
await expect(taskPage.getTaskLocator('Task to be deleted')).not.toBeVisible();
});
test('task creation sends the correct request payload', async ({ page }) => {
const taskPage = new TaskPage(page);
await taskPage.goto();
let capturedPayload: Record<string, unknown> = {};
// Intercept to read what the browser actually sends
await page.route('**/api/tasks', async route => {
if (route.request().method() === 'POST') {
capturedPayload = JSON.parse(route.request().postData() || '{}');
}
// Let it continue to the real server
await route.continue();
});
await taskPage.createTask('Validate this payload');
expect(capturedPayload.title).toBe('Validate this payload');
expect(capturedPayload.status).toBe('pending');
expect(capturedPayload.assignee).toBeDefined();
});
This pattern is invaluable when testing forms β assert the correct payload is being sent without depending on what the backend does with it. And because we're using the TaskPage.deleteTask() method built in Part 1, the test stays clean and readable. π―
π¬ HAR Files β Record Real Traffic, Replay It in Tests
HAR (HTTP Archive) is the most underused feature in Playwright's network toolkit.
The idea: Record real network traffic from your app once β save it to a file β replay it in tests without ever hitting the server again.
Perfect for:
- Third-party APIs you don't control (payment gateways, analytics, maps)
- Expensive API calls you don't want to make on every test run
- Flaky external dependencies you want to stabilize once and for all
Step 1 β Record the HAR file
Run this script once against your real running app. It records every network call made during the interaction.
// scripts/record-har.ts
import { chromium } from '@playwright/test';
import * as dotenv from 'dotenv';
dotenv.config();
(async () => {
const browser = await chromium.launch({ headless: false }); // headless: false so you can watch
const context = await browser.newContext();
// This tells Playwright: record all network traffic to this file
await context.routeFromHAR('./fixtures/tasks-har.har', { update: true });
const page = await context.newPage();
await page.goto(`${process.env.BASE_URL}/tasks`);
// Interact with the app β every network call gets saved
await page.getByRole('button', { name: 'New Task' }).click();
await page.getByLabel('Task title').fill('Recorded task');
await page.getByRole('button', { name: 'Save task' }).click();
await page.waitForTimeout(1000); // let the response settle before closing
await browser.close();
console.log('β
HAR recorded to fixtures/tasks-har.har');
})();
Run it once: npx ts-node scripts/record-har.ts
This creates fixtures/tasks-har.har β a JSON file containing every request and response. Commit it to your repo.
Step 2 β Replay it in your tests
// tests/network/api-mocking.spec.ts (add to existing file)
test('task flow using recorded HAR β no live server needed', async ({ page }) => {
// All matching network calls get served from the HAR file
await page.routeFromHAR('./fixtures/tasks-har.har', {
notFound: 'abort', // fail loudly if a request isn't in the HAR
url: '**/api/**', // only intercept API calls β let assets load normally
});
await page.goto('/tasks');
// Runs entirely against recorded responses β deterministic every time
await expect(page.getByRole('listitem')).toHaveCount(3);
});
Your tests are now completely decoupled from any live server. CI runs in half the time. Zero flakiness from external APIs. The HAR file is your source of truth. β
π§© Putting It All Together β A Real-World Scenario
Here's a scenario you'll actually face: a dashboard that loads from multiple APIs, one of which is a flaky third-party service you don't control.
// tests/network/error-simulation.spec.ts (add to existing file)
test('dashboard renders correctly even when analytics API is down', async ({ page }) => {
// Core tasks API β mock with known data
await page.route('**/api/tasks', async route => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify([
{ id: 1, title: 'Write unit tests', status: 'pending' },
{ id: 2, title: 'Deploy to staging', status: 'completed' },
]),
});
});
// User profile API β mock with known user
await page.route('**/api/users/me', async route => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ name: 'Faizal', role: 'admin' }),
});
});
// Third-party analytics API β simulate it being completely down
await page.route('**/analytics.thirdparty.com/**', async route => {
await route.abort('failed');
});
await page.goto('/dashboard');
// Core content must still render β the page should not crash
await expect(page.getByText('Write unit tests')).toBeVisible();
await expect(page.getByText('Hello, Faizal')).toBeVisible();
// Analytics widget should degrade gracefully
await expect(page.getByTestId('analytics-widget')).toContainText('Analytics unavailable');
// No full-page error banner β just the widget degrades
await expect(page.getByTestId('error-banner')).not.toBeVisible();
});
This is the kind of test that catches real production bugs. Most teams never write it β because without network interception, it's too hard to set up. With Playwright β it's under 30 lines. π₯
π Final Project Structure After Part 2
Every file listed below has been fully built across Part 1 and Part 2:
playwright-playbook/
βββ tests/
β βββ auth/
β β βββ login.spec.ts β
Part 1
β βββ tasks/
β β βββ task-management.spec.ts β
Part 1
β βββ network/ β
Part 2
β βββ api-mocking.spec.ts
β βββ error-simulation.spec.ts
β βββ network-assertions.spec.ts
βββ pages/
β βββ LoginPage.ts β
Part 1
β βββ TaskPage.ts β
Part 1 (createTask + deleteTask)
βββ fixtures/
β βββ auth.fixture.ts β
Part 1
β βββ tasks.json β
Part 2
β βββ empty-tasks.json β
Part 2
β βββ tasks-har.har β
Part 2 (generated via script)
βββ scripts/
β βββ record-har.ts β
Part 2
βββ .auth/ β git-ignored, auto-generated
β βββ admin.json
β βββ user.json
βββ global-setup.ts β
Part 1
βββ playwright.config.ts β
Part 1
βββ .env β git-ignored
βββ package.json
πΊοΈ What's Coming in This Series
Part 1 β Stop Writing Tests Like a Beginner β
Done
Part 2 β Network Interception: The Complete Guide β You are here
Part 3 β Multi-User, Multi-Tab & Context Testing
Part 4 β API Testing (The Underrated Superpower)
Part 5 β Visual Regression Testing
Part 6 β Debugging Like a Pro: Trace Viewer & Inspector
Part 7 β The CI/CD Setup Nobody Shows You
Part 8 β Playwright Meets AI: Agents, MCP & Self-Healing Tests
In Part 3, we tackle scenarios most automation frameworks simply can't handle β testing with multiple users simultaneously, multi-tab flows, and real-time features like live notifications. Playwright's browser context architecture makes this surprisingly clean. And the storageState files we created in Part 1 become the foundation for it.
π Before You Go
Network interception is the feature that makes Playwright genuinely powerful β not just as a UI testing tool, but as a full-stack testing platform.
Once you start owning the network layer in your tests:
- You stop depending on backend availability
- Your tests stop failing because of external APIs
- You start catching frontend bugs that only appear under specific API conditions
- Your test suite becomes fast, deterministic, and actually reliable
That's the goal. Not just tests that run β tests that you can trust. πͺ
Follow me so you don't miss Part 3 β where we go deep on multi-user and multi-tab testing. Two browser contexts. One test. Real-time feature validation that most frameworks can't even attempt.
Drop a comment below π
- Are you using
page.route()in your current test suite? - What's the most painful external dependency in your tests right now?
- Have you used HAR files before β or is that new to you?
Let's talk in the comments. π
Faizal Shaikh | Senior Automation Engineer | Playwright & AI Testing
Connect with me on LinkedIn












