A 500 error from the products API, a slow gateway that triggers a loading spinner, a network failure that tests whether the error message is helpful: none of these are easy to reproduce against a real backend on demand. page.route() intercepts any request your application makes and lets you return route.fulfill() with any status and body, delay and pass through with route.continue(), or simulate network failure with route.abort(). This guide covers the core patterns: JSON fixtures, error state testing, modifying real responses via route.fetch(), blocking third-party scripts, and waitForRequest/waitForResponse for asserting what the app actually sends.

Why Intercept Network Requests?

Testing states that are hard to reproduce:
  • Server returns 500 — what does the UI show?
  • API takes 10 seconds — is there a loading spinner?
  • Payment gateway is down — does checkout show a useful error?
Making tests faster and more reliable:
  • Mock the backend so tests don't depend on real data
  • Block analytics, ads, and tracking scripts that slow page load
  • Avoid rate limits on external APIs
Testing without a backend:
  • Develop UI tests before the API is built
  • Test edge cases that are hard to trigger in a real system

Basic Route Mocking

page.route() intercepts requests matching a URL pattern:

test('shows loading state', async ({ page }) => {
  // Intercept and delay the products API
  await page.route('/api/products', async (route) => {
    await new Promise(resolve => setTimeout(resolve, 3000));  // 3 second delay
    await route.continue();  // Then let the real request through
  });
  
  await page.goto('/products');
  
  // Loading spinner should be visible during the delay
  await expect(page.getByTestId('loading-spinner')).toBeVisible();
  
  // Wait for products to load
  await expect(page.getByTestId('product-card').first()).toBeVisible();
});

Returning Mock Responses

Instead of forwarding to the real server, return fake data:

const mockUsers = [
  { id: 1, name: 'Alice', email: 'alice@test.com', role: 'admin' },
  { id: 2, name: 'Bob', email: 'bob@test.com', role: 'member' },
];

test('users table shows all users', async ({ page }) => {
  await page.route('/api/users', async (route) => {
    await route.fulfill({
      status: 200,
      contentType: 'application/json',
      body: JSON.stringify(mockUsers),
    });
  });
  
  await page.goto('/admin/users');
  
  const rows = page.getByTestId('user-row');
  await expect(rows).toHaveCount(2);
  await expect(rows.first()).toContainText('Alice');
  await expect(rows.last()).toContainText('Bob');
});

Testing Error States

test('shows error when API fails', async ({ page }) => {
  await page.route('/api/products', async (route) => {
    await route.fulfill({
      status: 500,
      contentType: 'application/json',
      body: JSON.stringify({ error: 'Internal Server Error' }),
    });
  });
  
  await page.goto('/products');
  
  await expect(page.getByTestId('error-message')).toBeVisible();
  await expect(page.getByTestId('error-message')).toContainText('Something went wrong');
  await expect(page.getByTestId('retry-button')).toBeVisible();
});

test('shows empty state when no products', async ({ page }) => {
  await page.route('/api/products', async (route) => {
    await route.fulfill({
      status: 200,
      contentType: 'application/json',
      body: JSON.stringify([]),
    });
  });
  
  await page.goto('/products');
  
  await expect(page.getByTestId('empty-state')).toBeVisible();
  await expect(page.getByTestId('empty-state')).toContainText('No products found');
});

test('shows network error message', async ({ page }) => {
  await page.route('/api/products', async (route) => {
    await route.abort('failed');  // Simulate network failure
  });
  
  await page.goto('/products');
  
  await expect(page.getByTestId('network-error')).toBeVisible();
});

URL Patterns

page.route() supports glob patterns and regex:

// Exact URL
await page.route('/api/users', handler);

// Wildcard
await page.route('/api/users/*', handler);  // /api/users/1, /api/users/abc
await page.route('/api/**', handler);        // All API routes

// Regex
await page.route(/\/api\/users\/\d+/, handler);  // /api/users/123

// Glob with query string
await page.route('/api/products?*', handler);  // /api/products?page=1&limit=10

Intercepting and Modifying Requests

Read the actual request before deciding what to do:

test('uses correct auth header', async ({ page }) => {
  let capturedAuthHeader = '';
  
  await page.route('/api/users', async (route) => {
    // Capture the actual request header
    capturedAuthHeader = route.request().headers()['authorization'] || '';
    
    await route.fulfill({
      status: 200,
      body: JSON.stringify([]),
    });
  });
  
  // Do something that triggers the request
  await page.goto('/admin/users');
  
  expect(capturedAuthHeader).toMatch(/Bearer .+/);
});

test('sends correct request body', async ({ page }) => {
  let capturedBody = '';
  
  await page.route('/api/auth/login', async (route) => {
    capturedBody = route.request().postData() || '';
    
    await route.fulfill({
      status: 200,
      body: JSON.stringify({ token: 'fake-token', user: { id: 1 } }),
    });
  });
  
  await page.goto('/login');
  await page.fill('[data-testid="email"]', 'user@test.com');
  await page.fill('[data-testid="password"]', 'ValidPass1');
  await page.click('[data-testid="submit"]');
  
  const body = JSON.parse(capturedBody);
  expect(body.email).toBe('user@test.com');
  expect(body.password).toBe('ValidPass1');
});

Modifying Real Responses

Intercept a real request and modify its response:

test('handles extra user roles gracefully', async ({ page }) => {
  await page.route('/api/users/1', async (route) => {
    // Let the real request go through
    const response = await route.fetch();
    const body = await response.json();
    
    // Modify the response
    body.role = 'super-admin';  // This role might not exist in test DB
    
    await route.fulfill({
      status: 200,
      body: JSON.stringify(body),
    });
  });
  
  await page.goto('/users/1');
  
  // Test how the UI handles unexpected role values
  await expect(page.getByTestId('role-badge')).toBeVisible();
});

Blocking Third-Party Requests

Speed up tests by blocking tracking, analytics, and ads:

test.beforeEach(async ({ page }) => {
  // Block common third-party scripts
  await page.route('**/*.{png,jpg,jpeg,gif,svg,ico,woff,woff2}', route => route.abort());
  await page.route('**/google-analytics.com/**', route => route.abort());
  await page.route('**/googletagmanager.com/**', route => route.abort());
  await page.route('**/hotjar.com/**', route => route.abort());
  await page.route('**/intercom.io/**', route => route.abort());
  await page.route('**/sentry.io/**', route => route.abort());
});

Caution: blocking images might affect visual tests or layout-sensitive assertions.

Using page.waitForRequest and page.waitForResponse

Wait for specific network activity to happen:

test('form submission sends correct data', async ({ page }) => {
  await page.goto('/login');
  
  // Start waiting for the request BEFORE the action that triggers it
  const requestPromise = page.waitForRequest(req => 
    req.url().includes('/api/auth/login') && req.method() === 'POST'
  );
  
  await page.fill('[data-testid="email"]', 'user@test.com');
  await page.fill('[data-testid="password"]', 'ValidPass1');
  await page.click('[data-testid="submit"]');
  
  const request = await requestPromise;
  const body = JSON.parse(request.postData() || '{}');
  
  expect(body.email).toBe('user@test.com');
});

test('page reloads after save', async ({ page }) => {
  await page.goto('/profile');
  
  // Wait for the save API call
  const responsePromise = page.waitForResponse(resp => 
    resp.url().includes('/api/users') && resp.status() === 200
  );
  
  await page.fill('[data-testid="name"]', 'New Name');
  await page.click('[data-testid="save"]');
  
  const response = await responsePromise;
  const body = await response.json();
  expect(body.name).toBe('New Name');
});

Realistic Mock Data Patterns

// data/mocks/users.ts
export const mockUser = (overrides = {}) => ({
  id: Math.floor(Math.random() * 10000),
  email: `user_${Date.now()}@test.com`,
  name: 'Test User',
  role: 'member',
  createdAt: new Date().toISOString(),
  ...overrides,
});

export const mockPaginatedResponse = <T>(items: T[], page = 1, limit = 10) => ({
  data: items,
  page,
  limit,
  total: items.length,
  totalPages: Math.ceil(items.length / limit),
});

test('admin table handles 100 users', async ({ page }) => {
  const users = Array.from({ length: 100 }, (_, i) => 
    mockUser({ id: i + 1, name: `User ${i + 1}` })
  );
  
  await page.route('/api/users', async (route) => {
    const url = new URL(route.request().url());
    const pageNum = parseInt(url.searchParams.get('page') || '1');
    const limit = parseInt(url.searchParams.get('limit') || '10');
    const start = (pageNum - 1) * limit;
    const pageUsers = users.slice(start, start + limit);
    
    await route.fulfill({
      status: 200,
      body: JSON.stringify(mockPaginatedResponse(pageUsers, pageNum, limit)),
    });
  });
  
  await page.goto('/admin/users');
  
  // Verify pagination shows correct count
  await expect(page.getByTestId('total-count')).toContainText('100');
  await expect(page.getByTestId('user-row')).toHaveCount(10);  // First page
});

Summary

| Method | What it does |

|--------|-------------|

| page.route(url, handler) | Intercept requests matching URL |

| route.fulfill({...}) | Return a mock response |

| route.continue() | Let the real request through |

| route.abort('failed') | Simulate network failure |

| route.fetch() | Make the real request, get response |

| page.waitForRequest(filter) | Wait for a specific request to happen |

| page.waitForResponse(filter) | Wait for a specific response |

Network interception is one of the most powerful Playwright features for testing edge cases. Use it to test loading states, errors, empty states, and network failures — scenarios that are difficult or impossible to reproduce consistently with a real backend.

→ See also: Network Interception, Mocking, and Stubbing in Playwright | API Testing with Playwright: Beyond the UI | Waiting Strategies in Playwright: No More sleep()