Beyond the Mocking Memes: A Production-Grade Comparison of Network Interception in Playwright vs. Cypress
Your tests pass, but do your network mocks actually fire? Here is how Playwright and Cypress intercept at the protocol level — and where each one silently misses.

Network interception is one of the most consequential capabilities in the E2E testing toolkit. It lets you collapse complex backend states — loading, error, rate-limited, empty, partially failed — into deterministic test scenarios without standing up extra infrastructure or fighting with shared test databases. When it works, it is invisible. When it breaks, it fails silently: your test passes, the mock never fires, and a real network call returns unexpected data that your test was never designed to handle.
Playwright and Cypress both support network interception. Most comparisons stop at syntax. This post goes deeper: into the protocol layer each tool uses, why that layer choice determines what you can and cannot intercept, and the production gotchas that only surface under realistic conditions. By the end, you will understand not just how to use each API but why it behaves the way it does — which is the knowledge that lets you debug confidently when things go wrong.
How Does Playwright Intercept Network Requests at the Protocol Level?
Playwright uses CDP to intercept at the browser engine level — catching fetch, XHR, and prefetch via a single protocol hook, without patching JavaScript globals.
When you call page.route(pattern, handler) in Playwright, the library issues a Fetch.enable command to the browser via the Chrome DevTools Protocol (CDP). This instructs the browser engine to pause any outgoing request matching the pattern — before the request leaves the browser process — and emit a Fetch.requestPaused event to Playwright's Node.js process. Your handler receives a Route object, and the browser holds the paused request in place until you resolve it with one of three methods: route.fulfill(), route.continue(), or route.abort().
This design is not just an API choice — it has structural consequences for what you can intercept and when. Because the hook lives inside the browser engine, all request types share the same interception path: fetch(), XMLHttpRequest, link prefetch, <script src>, CSS @import, font loads, and service worker fetches (with the right context configuration). There is no separate code path for XHR versus Fetch — the engine-level hook catches both before JavaScript on the page is even aware they fired.
Two subtleties matter for production use. First, Playwright does not modify window.fetch or XMLHttpRequest.prototype. Applications that fingerprint native functions — for example, checking fetch.toString() for tamper detection — will see no difference between test and production. Second, routes are evaluated in reverse registration order (LIFO): the last handler registered for a URL pattern is the first one evaluated. This is intentional — it lets fixture-level defaults be overridden by test-level specifics — but it surprises developers who assume first-registered-wins behavior.
Scope isolation is also a first-class concept: routes registered on a Page intercept only that page's requests, while routes on a BrowserContext cover all pages and workers in that context. This matters for multi-tab flows and for intercepting requests made by service workers.
Example 1: Production-Grade Playwright Route Interception
Scenario: a user dashboard that loads a paginated list from /api/v1/users. We need to test four states — success, empty list, 401 authentication failure, and network failure — using a shared helper so individual tests stay readable. Each state exercises a different application code path.
// playwright/fixtures/route-helpers.ts
import type { Page, Route } from '@playwright/test';
export type UserApiState =
| { type: 'success'; users: Array<{ id: number; name: string; role: string }> }
| { type: 'empty' }
| { type: 'auth-error' }
| { type: 'network-failure' };
export async function mockUsersEndpoint(
page: Page,
state: UserApiState
): Promise<void> {
// Clear any previously registered handler for this URL to prevent stack buildup
// when the helper is called across multiple tests in the same context.
await page.unroute('**/api/v1/users**');
if (state.type === 'network-failure') {
// route.abort('failed') simulates a TCP connection failure.
// This is NOT the same as a 500 response — it exercises the app's
// retry/error-boundary path, not its HTTP error handler.
await page.route('**/api/v1/users**', (route: Route) => {
route.abort('failed');
});
return;
}
await page.route('**/api/v1/users**', async (route: Route) => {
// CRITICAL: route.fulfill() is async and MUST be awaited.
// Omitting await leaves the request pending until page timeout fires.
// The resulting error says "Timeout" — nothing about the missing await.
await route.fulfill({
status: state.type === 'auth-error' ? 401 : 200,
contentType: 'application/json',
headers: {
// Realistic headers prevent middleware that checks these from breaking
'x-request-id': 'test-' + Math.random().toString(36).slice(2),
'cache-control': 'no-store',
},
body: JSON.stringify(
state.type === 'auth-error'
? { error: 'Unauthorized', code: 'TOKEN_EXPIRED' }
: {
users: state.type === 'empty' ? [] : state.users,
total: state.type === 'empty' ? 0 : state.users.length,
}
),
});
});
}
// playwright/tests/dashboard.spec.ts
import { test, expect } from '@playwright/test';
import { mockUsersEndpoint } from '../fixtures/route-helpers';
test.describe('User Dashboard — API layer', () => {
test('renders user table on successful fetch', async ({ page }) => {
await mockUsersEndpoint(page, {
type: 'success',
users: [
{ id: 1, name: 'Alice Martínez', role: 'admin' },
{ id: 2, name: 'Bob García', role: 'viewer' },
],
});
await page.goto('/dashboard/users');
await expect(page.getByRole('table')).toBeVisible();
await expect(page.getByText('Alice Martínez')).toBeVisible();
});
test('shows empty-state UI when API returns no users', async ({ page }) => {
// Edge case: a 200 response with an empty array.
// Many UIs incorrectly display a loading spinner here instead of the empty state,
// because developers conflate "no data" with "not yet loaded".
await mockUsersEndpoint(page, { type: 'empty' });
await page.goto('/dashboard/users');
await expect(page.getByText('No users found')).toBeVisible();
await expect(page.getByRole('table')).not.toBeVisible();
});
test('redirects to login on 401', async ({ page }) => {
await mockUsersEndpoint(page, { type: 'auth-error' });
await page.goto('/dashboard/users');
await page.waitForURL('**/login**');
await expect(page.getByText('Your session has expired')).toBeVisible();
});
test('shows error banner on network failure', async ({ page }) => {
await mockUsersEndpoint(page, { type: 'network-failure' });
await page.goto('/dashboard/users');
await expect(page.getByRole('alert')).toBeVisible();
await expect(page.getByText('Could not connect')).toBeVisible();
});
});Why call page.unroute() before each registration? Playwright accumulates route handlers in a per-page stack. If tests share a browser context (common in Playwright component tests or when using custom fixtures without full isolation), a handler from a previous test can shadow the one registered by the current test — because routes are LIFO. Calling page.unroute(pattern) before re-registering resets the stack for that pattern without affecting routes for other URLs.
Why Did cy.intercept() Replace cy.route() — and Why Does It Matter?
cy.route() patched XHR only; cy.intercept (Cypress 6.0+) also captures fetch, closing the gap that caused silent test misses in every modern SPA.
Cypress's original network interception model — cy.server() + cy.route() — worked by patching window.XMLHttpRequest at runtime. Every XHR request the application made passed through Cypress's overridden prototype, where it could be inspected and stubbed. This worked well through the jQuery AJAX era. It broke as the ecosystem moved to fetch().
The fetch() API bypasses XMLHttpRequest entirely. When a React or Vue SPA switched from Axios (which defaults to XHR) to native fetch() or a library like ky, any cy.route() call targeting those requests silently stopped working. The test continued, a real network call went to the actual backend, and depending on whether CI had a live API server running, the test either passed accidentally or failed with a cryptic timeout. Neither outcome was useful. In our experience reviewing CI pipelines, this was one of the most common sources of non-deterministic test results in Cypress suites written before 2021.
cy.intercept(), introduced in Cypress 6.0 (per the Cypress 6.0 changelog), addresses this by moving interception lower in the stack. Cypress routes browser traffic through a local HTTP proxy server at launch time. The proxy registers matchers from cy.intercept() calls and applies them regardless of whether the JavaScript API is XHR or fetch() — the browser has no visibility into the interception. For HTTPS traffic, Cypress installs its own Certificate Authority certificate into the browser, allowing the proxy to decrypt, inspect, and optionally replace responses.
One important ordering difference from Playwright: Cypress evaluates intercept handlers in FIFO order — the first registered handler for a matching URL wins. This is the opposite of Playwright's LIFO behavior. If a shared beforeEach registers a catch-all and your test registers a specific handler, in Cypress the catch-all wins; in Playwright, the specific handler wins.
Example 2: The Same Dashboard Test Suite in Cypress
The same four states implemented in Cypress. Pay close attention to the forceNetworkError handling and the mandatory cy.wait() sequencing — both are common sources of test failures.
// cypress/support/route-helpers.ts
export type UserApiState =
| { type: 'success'; users: Array<{ id: number; name: string; role: string }> }
| { type: 'empty' }
| { type: 'auth-error' }
| { type: 'network-failure' };
export function mockUsersEndpoint(state: UserApiState): void {
if (state.type === 'network-failure') {
// GOTCHA: do NOT call cy.wait('@getUsers') after forceNetworkError.
// The request never completes, so cy.wait() will time out after
// defaultCommandTimeout (4 seconds by default). Assert UI directly instead.
cy.intercept('GET', '**/api/v1/users**', {
forceNetworkError: true,
}).as('getUsers');
return;
}
const statusCode = state.type === 'auth-error' ? 401 : 200;
const body =
state.type === 'auth-error'
? { error: 'Unauthorized', code: 'TOKEN_EXPIRED' }
: {
users: state.type === 'empty' ? [] : state.users,
total: state.type === 'empty' ? 0 : state.users.length,
};
cy.intercept('GET', '**/api/v1/users**', {
statusCode,
body,
headers: {
'x-request-id': 'test-' + Cypress._.uniqueId(),
'cache-control': 'no-store',
},
}).as('getUsers');
}
// cypress/e2e/dashboard.cy.ts
import { mockUsersEndpoint } from '../support/route-helpers';
describe('User Dashboard — API layer', () => {
it('renders user table on successful fetch', () => {
mockUsersEndpoint({
type: 'success',
users: [
{ id: 1, name: 'Alice Martínez', role: 'admin' },
{ id: 2, name: 'Bob García', role: 'viewer' },
],
});
cy.visit('/dashboard/users');
// cy.wait('@alias') is not optional here.
// Without it, assertions run against the DOM state BEFORE the response renders,
// creating flaky tests that pass on fast machines and fail in CI.
cy.wait('@getUsers').its('response.statusCode').should('eq', 200);
cy.findByRole('table').should('be.visible');
cy.findByText('Alice Martínez').should('be.visible');
});
it('shows empty-state UI when API returns no users', () => {
// Edge case: 200 OK with an empty array — not the same code path as an error
mockUsersEndpoint({ type: 'empty' });
cy.visit('/dashboard/users');
cy.wait('@getUsers');
cy.findByText('No users found').should('be.visible');
cy.findByRole('table').should('not.exist');
});
it('redirects to login on 401', () => {
mockUsersEndpoint({ type: 'auth-error' });
cy.visit('/dashboard/users');
cy.wait('@getUsers').its('response.statusCode').should('eq', 401);
cy.url().should('include', '/login');
cy.findByText('Your session has expired').should('be.visible');
});
it('shows error banner on network failure', () => {
mockUsersEndpoint({ type: 'network-failure' });
cy.visit('/dashboard/users');
// Skip cy.wait('@getUsers') — forceNetworkError aborts before completion
cy.findByRole('alert').should('be.visible');
cy.findByText('Could not connect').should('be.visible');
});
});API Comparison: Feature-by-Feature Breakdown
The table below covers capabilities you will reach for in production test suites. “Supported” means the feature works reliably without workarounds or third-party plugins.
| Feature | Playwright | Cypress |
|---|---|---|
| Interception layer | CDP (browser engine) | HTTP proxy + browser hooks |
Intercepts fetch() | Yes (always) | Yes (cy.intercept only; cy.route: no) |
| Intercepts XHR | Yes | Yes |
| Intercepts static assets (JS, CSS, images) | Yes | Yes |
| Modify request headers before send | route.continue({ headers }) | req.headers mutation in handler |
| Modify response body | route.fulfill({ body }) | res.body mutation in req.continue |
| Passthrough + selective mutation | await route.fetch() then fulfill | req.continue(res => ...) |
| Simulate network failure | route.abort('failed') | forceNetworkError: true |
| URL glob patterns | Yes (minimatch-style) | Yes (minimatch-style) |
| Regex URL patterns | Yes | Yes |
| HAR file replay | Yes (page.routeFromHAR, v1.23+) | Community plugins only |
| Cross-origin iframe requests | Yes | Limited (same-origin proxy model) |
| Multi-tab interception | Yes (context.route()) | No (single-tab model) |
| Service worker request interception | Yes (with context-level route) | Partial (proxy catches most, not all) |
| Handler evaluation order | LIFO (last registered wins) | FIFO (first registered wins) |
Example 3: Passthrough with Selective Response Mutation
Full mocking replaces every API response with a static fixture. This is fast and deterministic, but it creates fixture drift: when the real API changes its response shape, your fixtures become stale and your tests remain green. A more surgical pattern is selective mutation — let the real request complete, then modify only the fields you care about. Both tools support this, but the implementations reveal architectural differences.
Scenario: testing what the UI does when a user profile has a null email — a state that should never occur in production but can appear after OAuth sign-ups that skip the email step, or through legacy data migrations.
// ── PLAYWRIGHT APPROACH ──────────────────────────────────────────────────────
// playwright/tests/profile-edge-cases.spec.ts
import { test, expect } from '@playwright/test';
import type { Route } from '@playwright/test';
test('shows email-required banner when profile.email is null', async ({ page }) => {
await page.route('**/api/v1/profile', async (route: Route) => {
let realResponse;
try {
// Forward the request to the real backend
realResponse = await route.fetch();
} catch {
// Backend unavailable (common in CI without a dev server running).
// Fall back to a minimal fixture so CI does not block on infrastructure.
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
id: 1,
name: 'Test User',
email: null,
emailVerified: false,
}),
});
return;
}
const body = await realResponse.json();
// Inject the edge-case field into an otherwise real response.
// route.fulfill({ response }) preserves the status code and all original headers.
await route.fulfill({
response: realResponse,
body: JSON.stringify({ ...body, email: null, emailVerified: false }),
});
});
await page.goto('/profile');
await expect(page.getByRole('alert', { name: /email required/i })).toBeVisible();
// Verify the rest of the profile rendered from real (or fallback) data
await expect(page.getByTestId('profile-name')).not.toBeEmpty();
});
// ── CYPRESS APPROACH ──────────────────────────────────────────────────────────
// cypress/e2e/profile-edge-cases.cy.ts
it('shows email-required banner when profile.email is null', () => {
cy.intercept('GET', '**/api/v1/profile', (req) => {
req.continue((res) => {
// res.body is already parsed for JSON content-type responses.
// Mutate it in-place — Cypress sends the modified body automatically.
// There is no equivalent of Playwright's try/catch here; if the backend
// is down, this test will fail with a connection error rather than falling
// back gracefully. Plan accordingly in CI.
res.body = { ...res.body, email: null, emailVerified: false };
});
}).as('getProfile');
cy.visit('/profile');
cy.wait('@getProfile').its('response.statusCode').should('eq', 200);
cy.findByRole('alert', { name: /email required/i }).should('be.visible');
cy.findByTestId('profile-name').should('not.be.empty');
});When to use passthrough vs. full mocks: Passthrough mutation is valuable for edge-case validation against a running server — it keeps your mock surface small (one field, not the whole response shape). Full mocks are better for CI isolation, where you cannot guarantee backend availability. In practice, many teams use full mocks in CI and passthrough mutation in integration environments. The Playwright version above handles the CI case explicitly with a try/catch fallback; the Cypress version does not — structure your test environment accordingly.
Edge Cases and Gotchas
The following scenarios are the most commonly reported sources of network interception failures. In our experience reviewing QA teams' test suites, these account for the majority of “passes locally, fails in CI” issues related to network mocking.
- Playwright: missing await on route.fulfill().
route.fulfill()is async. Calling it withoutawaitinside an async handler leaves the request pending until Playwright's page timeout fires. The error message is a generic timeout — nothing points to the missingawait. Always wrap route handlers in async and await every resolution call. - Playwright: LIFO handler ordering surprises. If a shared fixture registers a catch-all for
**/api/**and a test then registers a specific handler for**/api/v1/users**, the specific handler evaluates first (LIFO). This is the behavior you want. But if you accidentally register the specific handler before the catch-all, the catch-all runs first and your specific mock is unreachable. - Cypress: asserting before cy.wait(). The most common Cypress mistake. Cypress commands are enqueued asynchronously, and assertions placed before
cy.wait('@alias')can execute against the pre-response DOM state. The result is a test that passes on fast machines (the response arrives before the assertion) and fails in CI (slower network or CPU). Alwayscy.wait('@alias')before asserting on anything the response populates. - Cypress: cy.wait() after forceNetworkError. With
forceNetworkError: true, the request is aborted before any response is returned.cy.wait('@alias')waits for a completed request — which never arrives — and times out. Skip the wait and assert directly on the UI error state. - Both: service worker fetch requests. Requests made inside a service worker run in a separate execution context. For Playwright, register the route at
context.route()level (notpage.route()) to catch service worker fetches. For Cypress, the proxy catches most browser traffic including service worker requests, but complex caching strategies may pre-empt interception. Verify with DevTools network logs if your app uses a service worker. - Both: WebSocket traffic is not interceptable. Neither tool can modify WebSocket message payloads in flight. Playwright's
page.on('websocket', ...)provides read-only observation only. For applications where WebSockets carry critical flow state, design a mockable transport layer so tests can inject messages without modifying the production WS client. - Playwright: HAR mode and unmatched requests. When using
page.routeFromHAR(), any request absent from the HAR file errors by default. UsenotFound: 'fallback'to pass unmatched requests through, or'abort'for strict coverage enforcement. - Both: query string matching. The glob pattern
**/api/v1/usersdoes not matchhttps://api.example.com/api/v1/users?page=2&limit=20. The trailing path segment matches, but the query string does not. Use**/api/v1/users*(trailing star) or a regex to match URLs with query parameters.
Debugging and Troubleshooting Network Interception Failures
When a route handler is not firing, the root cause is almost always one of four things: the URL pattern does not match the actual request URL, the handler was registered after the request fired, the request originated from an unexpected context, or the handler itself threw an unhandled error. Here is how to diagnose each.
Step 1: log every request to find the real URL. Register a catch-all diagnostic route before any test-specific routes to see exactly what URLs the browser is requesting:
// Playwright: diagnostic catch-all — add temporarily, remove before committing
await page.route('**/*', async (route, request) => {
console.log('[ROUTE]', request.method(), request.url());
await route.continue(); // do not block anything
});
// Cypress: equivalent diagnostic intercept
cy.intercept('*', (req) => {
console.log('[INTERCEPT]', req.method, req.url);
req.continue();
});With the real URL visible, you will often immediately see the mismatch: a versioned path (/api/v2/users instead of /api/v1/users), an unexpected hostname (CDN URL instead of the API origin), or a query parameter that the glob did not account for.
Step 2: verify registration order relative to navigation. In Playwright, routes registered after page.goto() will miss requests fired during that navigation. Register all routes before goto(). In Cypress, cy.intercept() must appear before the cy.visit() that triggers the request — Cypress's command queue executes in order, and the intercept must be active before the browser receives the URL.
Step 3: check request origin. If a request appears in the browser DevTools Network panel but your handler still does not fire, check the Initiator column. Requests initiated by service workers, shared workers, or browser extensions may bypass page-scoped routes. For Playwright, promote the route from page.route() to context.route(). For Cypress, if the request appears in DevTools but not in the Cypress command log, the request may have completed before the page was instrumented.
Step 4: guard against handler exceptions. In Playwright, an unhandled throw inside an async route handler leaves the request pending until timeout. Wrap your handler body in try/catch and always resolve the route — even in the error path:
// Playwright: defensive handler that always resolves the route
await page.route('**/api/v1/profile', async (route: Route) => {
try {
const response = await route.fetch();
const body = await response.json();
await route.fulfill({
response, // preserves status and headers from the real response
body: JSON.stringify({ ...body, email: null }),
});
} catch (error) {
// Log clearly so CI output is searchable
console.error('[route handler error] /api/v1/profile:', error);
// Abort cleanly — the app sees a network failure, not an infinite hang
await route.abort('failed');
}
});Cypress-specific: diagnosing multiple intercepts for the same route. When a beforeEach and a test both register intercepts for the same URL, Cypress's FIFO ordering means the beforeEach handler wins. Use cy.get('@alias.all') to inspect all matched requests for an alias, which reveals which handler actually served each request and how many times the route was hit.
Choosing the Right Tool for Your Network Testing Strategy
Both tools have matured significantly. The architectural differences described here surface most often in applications with rich network behavior — service workers, cross-origin iframes, multi-tab flows, or complex request sequences. For a straightforward CRUD SPA with a single REST backend, either tool will serve you well and the choice comes down to team familiarity and broader toolchain fit.
- Reach for Playwright when: you need cross-origin iframe interception, multi-tab flows, HAR replay, or reliable service worker coverage. The CDP-level hook gives you complete request coverage with fewer edge cases and a predictable LIFO override model.
- Reach for Cypress when: your team is invested in the Cypress ecosystem and your application does not exercise cross-origin iframes or multi-tab navigation.
cy.wait('@alias')paired with.its('response.statusCode')is genuinely readable — non-specialist engineers can follow the intent without knowing the internals. - Either way: migrate off cy.route() now. If your Cypress suite still contains
cy.server() + cy.route(), those calls are silently ignoring everyfetch()-based request in your application. The migration tocy.intercept()is straightforward, the API is more expressive, and the coverage gap is critical enough to treat as a blocking issue.
The memes about network mocking persist because the failure modes are invisible — your test stays green while the mock does nothing. Understanding the protocol layer each tool operates at is what moves you from “my tests seem to pass” to “I know exactly what my tests cover and why.”
Ready to strengthen your test automation?
Desplega.ai helps QA teams build robust test automation frameworks with expert guidance on Playwright, Cypress, and modern testing strategies.
Get StartedFrequently Asked Questions
Can Playwright intercept WebSocket messages in addition to HTTP requests?
Playwright can observe WebSocket connections via page.on("websocket") but cannot modify in-flight message payloads. Design your app with a mockable transport abstraction for WS testing.
Why does cy.intercept sometimes miss requests in my Cypress tests?
Common causes: intercept registered after the request fired, URL pattern mismatch, or a service worker origin. Always set up cy.intercept before cy.visit() to prevent race conditions.
Does Playwright page.route() work for requests originating from iframes?
Yes. page.route() intercepts all page frames including cross-origin iframes. Cypress has long-standing same-origin proxy limitations that make cross-origin frame interception unreliable.
How do I mock GraphQL requests in Playwright and Cypress?
Both intercept GraphQL over HTTP. Parse the request body for the operation name, then return specific mocks. Playwright uses route.fulfill(); Cypress uses req.reply() in the intercept handler.
Can I record real network traffic and replay it in Playwright tests?
Playwright v1.23+ supports native HAR replay via page.routeFromHAR(). Cypress relies on community plugins. HAR replay speeds up authoring for complex multi-request API sequences.
Related Posts
Hot Module Replacement: Why Your Dev Server Restarts Are Killing Your Flow State | desplega.ai
Stop losing 2-3 hours daily to dev server restarts. Master HMR configuration in Vite and Next.js to maintain flow state, preserve component state, and boost coding velocity by 80%.
The Flaky Test Tax: Why Your Engineering Team is Secretly Burning Cash | desplega.ai
Discover how flaky tests create a hidden operational tax that costs CTOs millions in wasted compute, developer time, and delayed releases. Calculate your flakiness cost today.
The QA Death Spiral: When Your Test Suite Becomes Your Product | desplega.ai
An executive guide to recognizing when quality initiatives consume engineering capacity. Learn to identify test suite bloat, balance coverage vs velocity, and implement pragmatic quality gates.