Intercepting the Truth: Playwright vs. Cypress Network Mocking Patterns for Distributed Systems
When your distributed system has five microservices and any one of them can take down your test suite, it's time to own the network — completely.

Modern web applications rarely talk to a single service. A checkout flow might touch an auth service, a cart API, an inventory system, a payment processor, and a notification queue — all in a single user action. Testing that flow end-to-end against real services is slow, fragile, and impossible to control. When the payment sandbox goes down during your CI run in Madrid, your entire test suite fails — and you spend the next hour ruling out a code regression that never existed.
Network mocking solves this by giving your tests ownership of every HTTP request the browser makes. But “mocking the network” means different things depending on where in the stack the interception happens. Playwright and Cypress both offer powerful network interception APIs, but they operate at fundamentally different layers — and that difference matters enormously when you're testing distributed systems with complex request patterns, GraphQL endpoints, and timing-sensitive workflows.
This post goes beyond the basics. We'll explore how each tool's interception mechanism works internally, walk through three production-grade code examples including multi-service failure simulation, GraphQL operation routing, and request ordering verification — then cover the edge cases and debugging techniques that separate reliable test suites from flaky ones.
How Does Playwright's page.route() Actually Work Under the Hood?
Playwright intercepts via CDP — below JavaScript, before DNS resolution — giving your test complete control over what the browser sends and receives.
Playwright's page.route() hooks into the browser at the Chrome DevTools Protocol (CDP) level, using the Fetch.enable and Fetch.fulfillRequest CDP commands for Chromium-based browsers, with equivalent mechanisms for Firefox (via its remote debugging protocol) and WebKit. This is not JavaScript-level interception — it operates below the browser's JavaScript engine. When you call page.route("**/api/**", handler), Playwright instructs the browser kernel to pause matching outbound requests before they leave the browser process, routing them to your Node.js test code for inspection or replacement.
This architecture has important practical consequences. Because interception happens at the OS network layer, your route handler can return responses for URLs that don't resolve — or even don't exist. You can mock https://payments.internal/api/v3/charge without that hostname resolving in your CI environment. Playwright never attempts the TCP connection for fulfilled routes. This also means route handlers run in your test process, not in the page's JavaScript context — your mocks cannot interfere with the application's own fetch wrappers or service workers.
Route matching supports glob patterns, regular expressions, and predicate functions. When multiple routes match a URL, they are evaluated in reverse registration order — the last page.route() call wins. A handler can call route.fallback() to pass control to the next matching route, or route.continue() to forward the request to the real network. This composability enables layered mocking strategies: intercept specific failure scenarios at the top, let everything else through at the bottom.
Production Example 1: Simulating Cascading Failures Across Microservices
This test covers a product page that queries an inventory service with automatic retry logic. The inventory service returns 503 on the first two calls, then recovers with limited stock. We also verify that the cart correctly rejects quantities exceeding recovered stock — an edge case that only surfaces when the retry path succeeds. Notice the difference between route.abort() (TCP-level failure) and a 503 HTTP response — the application may handle these differently, so we test the specific failure mode it handles.
import { test, expect } from '@playwright/test'
// Tests a product page with inventory retry logic.
// Inventory service fails twice (503) before recovering with limited stock.
// Edge case: cart must reject quantities exceeding the recovered stock.
test('cart enforces stock limits after inventory service retry', async ({ page }) => {
let inventoryCallCount = 0
// Inventory service: fail first 2 calls with 503, succeed on 3rd
await page.route('**/api/inventory/**', async (route) => {
inventoryCallCount++
if (inventoryCallCount <= 2) {
await route.fulfill({
status: 503,
headers: {
'Content-Type': 'application/json',
'Retry-After': '1',
'X-Service': 'inventory-service',
'X-Error-Code': 'SERVICE_OVERLOADED',
},
body: JSON.stringify({
error: 'Service temporarily unavailable',
retryAfterMs: 1000,
}),
})
return // CRITICAL: return after fulfill — calling continue() after fulfill() throws
}
// Third call: success with limited reserved stock
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
itemId: 'prod-123',
stock: 3,
warehouse: 'EU-WEST-1',
reserved: true, // Item partially reserved — edge case the UI must handle
}),
})
})
// Cart service: block quantities that exceed the recovered stock
await page.route('**/api/cart/add', async (route) => {
const postData = route.request().postData()
const body = postData ? (JSON.parse(postData) as { quantity: number }) : { quantity: 0 }
if (body.quantity > 3) {
await route.fulfill({
status: 422,
contentType: 'application/json',
body: JSON.stringify({
error: 'Quantity exceeds available stock',
available: 3,
requested: body.quantity,
}),
})
return
}
// Within stock limit — let the real cart service handle it
await route.continue()
})
await page.goto('/products/prod-123')
// During the first two 503 responses, the UI should show a retry indicator
await expect(page.getByTestId('inventory-status')).toHaveText('Checking availability...')
// After the third call succeeds, stock count becomes visible
await expect(page.getByTestId('stock-count')).toHaveText('3 left', { timeout: 5000 })
// Confirm exactly 3 network calls fired (2 failures + 1 success)
expect(inventoryCallCount).toBe(3)
// Test the edge case: requesting more than available stock triggers 422
await page.getByTestId('quantity-input').fill('5')
await page.getByTestId('add-to-cart').click()
await expect(page.getByTestId('stock-error')).toHaveText('Only 3 available')
})Critical gotcha: route.continue() and route.fulfill() are mutually exclusive per request. Calling both throws a synchronous Request already handled error that will not appear in your test output without an explicit try/catch. Always use early return after every await route.fulfill() in conditional handlers to prevent this silent failure mode.
When Does Cypress cy.intercept() Outperform Playwright for Network Mocking?
Cypress wins with .as() aliasing for cy.wait(), mid-flight req.on() hooks, and a command log that surfaces intercept timing in sequential API flows.
Cypress cy.intercept(), introduced in Cypress 6.0 as a replacement for the XHR-only cy.route(), operates via a different architecture. Cypress runs your test code and the browser in the same event loop, routing all traffic through a local proxy server. When a test navigates to a URL, every HTTP request passes through this proxy, which can pause, inspect, and modify requests before forwarding them. This gives Cypress access to the full HTTP request lifecycle — including the ability to hook into responses mid-stream via req.on("response", handler), modify response bodies, or inject delays into specific responses without touching others.
The aliasing system is one of Cypress's strongest differentiators for sequential testing. When you write cy.intercept(...).as("myRequest"), you can then use cy.wait("@myRequest") to pause test execution until that specific request completes and assert on both the request and response. This makes it natural to test multi-step flows where each step depends on the previous API response — a pattern common in distributed checkout flows. Playwright achieves similar behavior with page.waitForResponse(), but Cypress's aliasing integrates more idiomatically with its command queue model and surfaces more detail in the Test Runner command log for debugging.
The key architectural tradeoff: Cypress's proxy approach means it intercepts at the HTTP level rather than the CDP/kernel level. This gives it broader protocol visibility in some scenarios (including some cross-origin cases), but also means its WebSocket support has historically been more limited and experimental compared to Playwright's native routeWebSocket(). Cypress also runs tests in a single browser window, while Playwright supports multi-page and multi-context testing — which matters for distributed system tests that involve multiple user sessions or browser-to-browser communication.
Production Example 2: GraphQL Operation Routing in Cypress
GraphQL presents a unique mocking challenge: every request goes to a single endpoint (/graphql), but each represents a fundamentally different operation. Routing by operationName is the production-correct approach. This example also demonstrates a critical gotcha that catches teams by surprise: per the GraphQL specification, operation errors are returned as HTTP 200 responses with an errors array — never as 4xx status codes. Mocking a 402 for a payment decline will never match your application's error handler.
// cypress/e2e/checkout-graphql.cy.ts
// Route all GraphQL requests by operationName for precise per-operation mocking.
// GraphQL errors are HTTP 200 + errors array — never 4xx. Per GraphQL spec.
describe('Checkout with mocked GraphQL payment API', () => {
beforeEach(() => {
cy.intercept('POST', '**/graphql', (req) => {
// Guard: if body is missing or not parsed, pass through
if (!req.body || typeof req.body.operationName !== 'string') {
req.continue()
return
}
const { operationName, variables } = req.body as {
operationName: string
variables?: Record<string, unknown>
}
switch (operationName) {
case 'GetCart':
req.reply({
statusCode: 200,
body: {
data: {
cart: {
id: 'cart-abc',
items: [{ id: 'prod-123', quantity: 2, price: 29.99 }],
total: 59.98,
currency: 'EUR',
},
},
},
})
break
case 'ProcessPayment': {
const retryAttempt = (variables?.retryAttempt as number) ?? 0
if (retryAttempt === 0) {
// Edge case: card decline comes back as HTTP 200 with errors array.
// If you test for a 402 here, you will never find it — GraphQL spec forbids it.
req.reply({
statusCode: 200,
body: {
data: null,
errors: [
{
message: 'Card declined',
extensions: {
code: 'PAYMENT_DECLINED',
declineCode: 'insufficient_funds',
},
},
],
},
})
} else {
req.reply({
statusCode: 200,
body: {
data: {
processPayment: {
transactionId: 'txn-xyz-789',
status: 'COMPLETED',
receipt: 'https://receipts.example.com/txn-xyz-789',
},
},
},
})
}
break
}
case 'GetUserProfile':
// Simulate a slow profile load to test skeleton UI rendering
req.on('response', (res) => {
res.setDelay(400)
})
req.reply({
statusCode: 200,
body: {
data: {
user: { id: 'user-456', name: 'Ana García', email: 'ana@example.com' },
},
},
})
break
default:
// CRITICAL: Always pass through unknown operations.
// Blocking unexpected queries causes UI failures that look like
// application bugs, not mock configuration issues.
req.continue()
}
}).as('graphql')
})
it('handles payment card decline then successful retry', () => {
cy.visit('/checkout')
// Wait for cart to load and verify the data
cy.wait('@graphql').its('request.body.operationName').should('eq', 'GetCart')
cy.get('[data-testid="cart-total"]').should('contain', '59.98')
// Initiate payment — first attempt will be declined
cy.get('[data-testid="pay-button"]').click()
cy.wait('@graphql').its('request.body.operationName').should('eq', 'ProcessPayment')
// App should display the specific decline message, not a generic error
cy.get('[data-testid="payment-error"]')
.should('be.visible')
.and('contain', 'Card declined')
// User updates payment method and retries — second attempt succeeds
cy.get('[data-testid="retry-payment"]').click()
cy.wait('@graphql')
cy.get('[data-testid="order-confirmation"]')
.should('be.visible')
.and('contain', 'txn-xyz-789')
})
})Side-by-Side Comparison: Playwright vs. Cypress Network Mocking
| Capability | Playwright (page.route) | Cypress (cy.intercept) |
|---|---|---|
| Interception layer | CDP / browser kernel | HTTP proxy (localhost) |
| Fetch API support | Yes (CDP level) | Yes (since Cypress 6.0) |
| XHR support | Yes | Yes |
| WebSocket interception | Yes (routeWebSocket) | Partial / experimental |
| Response delay simulation | setTimeout in handler | res.setDelay(ms) |
| Request aliasing / wait | page.waitForResponse() | Native .as() + cy.wait() |
| Mid-flight response hook | No built-in | req.on('response', ...) |
| Network abort simulation | route.abort('timedout') | req.destroy() |
| Multi-browser support | Chromium, Firefox, WebKit | Chromium, Firefox, WebKit |
| Multi-context testing | Native (BrowserContext) | Single window only |
| Cross-origin iframe intercept | context.route() required | Proxy-level (broader) |
Production Example 3: Verifying Request Ordering Across a Service Mesh
Distributed systems have ordering constraints that unit tests cannot verify: auth must precede payment, notification must fire after the successful payment attempt (not after a failed one), and slow services must not block the UI from rendering partial data. This example simulates a four-service checkout flow — auth, cart, payment, and notifications — and uses a timestamped request log to assert on ordering. It also demonstrates the difference between route.abort('timedout') (a TCP-level failure that triggers a network error in the application) and a 504 Gateway Timeout (a valid HTTP response your error handler may treat differently).
import { test, expect } from '@playwright/test'
const SERVICE_PATTERNS = {
auth: '**/services/auth/**',
cart: '**/services/cart/**',
payment: '**/services/payment/**',
notifications: '**/services/notifications/**',
} as const
type ServiceName = keyof typeof SERVICE_PATTERNS
interface RequestRecord {
service: ServiceName
url: string
timestamp: number
}
test.describe('Multi-service checkout: request ordering and failure recovery', () => {
let requestLog: RequestRecord[] = []
let paymentCallCount = 0
test.beforeEach(async ({ page }) => {
requestLog = []
paymentCallCount = 0
// Auth: always succeeds immediately — not the system under test here
await page.route(SERVICE_PATTERNS.auth, async (route) => {
requestLog.push({ service: 'auth', url: route.request().url(), timestamp: Date.now() })
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ userId: 'user-456', sessionValid: true, roles: ['customer'] }),
})
})
// Cart: succeeds but with simulated 300ms DB read latency
await page.route(SERVICE_PATTERNS.cart, async (route) => {
requestLog.push({ service: 'cart', url: route.request().url(), timestamp: Date.now() })
await new Promise<void>((resolve) => setTimeout(resolve, 300))
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
cartId: 'cart-789',
items: [{ sku: 'SKU-001', qty: 1, unitPrice: 49.99 }],
subtotal: 49.99,
tax: 5.0,
total: 54.99,
}),
})
})
// Payment: TCP abort on first call (cold container start), success on second.
// route.abort('timedout') simulates a connection that never completes —
// different from a 504 which is a valid HTTP response with a status code.
// Your app may handle these differently (network error vs. HTTP error).
await page.route(SERVICE_PATTERNS.payment, async (route) => {
paymentCallCount++
requestLog.push({ service: 'payment', url: route.request().url(), timestamp: Date.now() })
if (paymentCallCount === 1) {
await route.abort('timedout')
return
}
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
paymentId: 'pay-001',
status: 'AUTHORIZED',
gateway: 'stripe',
last4: '4242',
}),
})
})
// Notifications: always accept — we only care about call count and timing
await page.route(SERVICE_PATTERNS.notifications, async (route) => {
requestLog.push({
service: 'notifications',
url: route.request().url(),
timestamp: Date.now(),
})
await route.fulfill({ status: 202, body: 'accepted' })
})
})
test('auth precedes payment; notifications fire once after successful payment', async ({
page,
}) => {
await page.goto('/checkout/confirm')
await page.getByRole('button', { name: 'Place Order' }).click()
// After the first payment TCP abort, the UI should show a retry indicator
await expect(page.getByTestId('payment-retry-indicator')).toBeVisible()
// Retry logic eventually succeeds — allow up to 10s
await expect(page.getByTestId('order-success')).toBeVisible({ timeout: 10000 })
// --- Ordering assertions ---
const authEntry = requestLog.find((r) => r.service === 'auth')
const paymentEntries = requestLog.filter((r) => r.service === 'payment')
const notifEntries = requestLog.filter((r) => r.service === 'notifications')
expect(authEntry).toBeDefined()
// Auth must precede all payment attempts (including the failed one)
paymentEntries.forEach((pe) => {
expect(authEntry!.timestamp).toBeLessThan(pe.timestamp)
})
// Exactly 2 payment calls: 1 abort + 1 success
expect(paymentCallCount).toBe(2)
// Notification fires exactly once — not after the failed attempt, only after success
expect(notifEntries).toHaveLength(1)
const lastPaymentTime = Math.max(...paymentEntries.map((p) => p.timestamp))
expect(notifEntries[0].timestamp).toBeGreaterThan(lastPaymentTime)
})
})Edge Cases and Gotchas
- The “already handled” Playwright error: Calling both
route.continue()androute.fulfill()on the same request throws synchronously. This is especially easy to trigger in handlers where multiple branches fall through to shared code below the switch. Always use earlyreturnafter every fulfillment call. - GraphQL errors are HTTP 200: Per the GraphQL specification, operation failures are always returned with a 200 status code and an
errorsarray in the body. Testing for a 402 or 422 when mocking a payment decline will never trigger your application's error handler. Mock withstatusCode: 200, body: { data: null, errors: [...] }. - CORS preflight in Playwright:
OPTIONSpreflight requests for cross-origin calls may be intercepted separately from the actual request. If your route only matchesPOST, the preflight 404s and the browser blocks the real request silently. Add a route forOPTIONSthat returns the appropriate CORS headers, or match both methods in your handler. - route.abort() vs 504 vs connection refused: These three failure modes produce different JavaScript error types in your application.
route.abort('timedout')causes a TypeError with a network message. A 504 is a valid HTTP response your app's catch block may handle differently. Test the failure mode your application actually handles — anecdotally, testing a 504 when the real failure is a TCP timeout is one of the most common sources of false positives in distributed system test suites. - Response body encoding in Playwright:
route.fulfill({ body: myObject })does not JSON-serialize automatically. Passing a plain JavaScript object produces the string[object Object]. Always useJSON.stringify()explicitly and setcontentType: 'application/json'. - Cypress and cross-origin iframes:
cy.intercept()operates at the proxy level and generally intercepts requests from iframes, but cross-origin iframes with stricter security policies may bypass the proxy depending on browser configuration. Validate your specific iframe setup in a real environment before relying on mocked iframe requests in CI.
Debugging and Troubleshooting Network Mock Failures
Network mock failures are uniquely difficult to diagnose because they rarely look like mock failures. The test fails with a UI assertion error — the element you expected isn't there — and you spend time debugging the application when the real problem is that your mock never fired and the request went to the real API (or silently failed for an unrelated reason). These are the most common failure modes and how to isolate them.
Symptom: Mock never fires — request hits the real API
In Playwright, add a console.log inside your route handler and run headed (npx playwright test --headed). If the log never appears, your URL pattern doesn't match. Launch the Playwright Inspector with PWDEBUG=1 to see all requests. A common cause: **/api/** won't match /api/v2/users if your glob is missing a double-star for path segments. Use page.route("**", ...) temporarily to log all URLs being requested.
In Cypress, open the Test Runner and check the Command Log — every registered intercept appears there, annotated with whether it was matched. If your intercept is missing from the log, the pattern didn't match. Use cy.intercept("**", (req) => console.log(req.url)) temporarily to log every outbound URL.
Symptom: Mock fires but the response body is ignored
In Playwright, check that you're setting contentType: 'application/json' (or the equivalent Content-Type header) in your route.fulfill() call. Without it, some frameworks won't parse the response body as JSON even if it is valid JSON text. In Cypress, set headers: { "Content-Type": "application/json" } in your reply. Also verify you're using JSON.stringify() — passing a plain object produces the string [object Object] silently.
Symptom: Test passes locally but fails in CI
A common cause is a race between route registration and navigation. If page.route() is called after page.goto() (or interleaved with it), the page may have already fired some requests before your mock is registered. Always register all routes before navigating. You can also use page.routeFromHAR() to replay a recorded session, which sidesteps timing-dependent registration entirely.
Another CI-specific cause: your test environment may have a real network connection to the services you think are mocked. Run with network access disabled (Playwright's offline: true browser context option, or Cypress's cy.intercept with a forceNetworkError fallback) to confirm which requests are escaping your mocks.
Symptom: cy.wait(@alias) times out even though the request fires
Cypress only counts a request toward an alias if the intercept was registered before the request was made. If your component fires the request on mount (before cy.visit() resolves), the alias is already consumed before you call cy.wait(). The fix: always register intercepts in beforeEach before cy.visit(). Cypress guarantees that beforeEach completes before any navigation occurs.
Choosing the Right Approach for Your Architecture
Both Playwright and Cypress offer production-ready network mocking. The choice depends on your specific testing constraints. Playwright's CDP-level interception gives you deeper control and better multi-context support — it's the natural choice if you test across multiple browser contexts, need to verify WebSocket frames, run tests against Firefox and WebKit as primary targets, or need to assert on precise request ordering. In our experience, teams in Barcelona and Madrid adopting Playwright for new automation suites benefit most from its type-safe API and its ability to capture request timing without wrapping everything in promise chains.
Cypress wins on developer experience for sequential, command-driven flows. Its aliasing and wait model is more intuitive for API-driven SPAs, and the command log makes it significantly easier to debug why an intercept didn't fire in the expected order. Teams in Valencia and Malaga running Cypress on React or Vue SPAs with complex async data fetches often find the Cypress model maps more naturally to their mental model of “wait for this request, then assert on what changed.”
The most pragmatic approach for new projects: pick based on your existing team expertise, invest in a shared fixtures library that both tools can consume, and resist the urge to mix tools at the same test layer. The patterns shown here — cascading failure simulation, GraphQL operation routing, and request ordering verification — translate directly to either tool with minor syntax adjustments. What matters is that your tests own the network completely, so your CI runs in Madrid don't depend on a payment sandbox that might be down.
Ready to strengthen your test automation?
Desplega.ai helps QA teams build robust test automation frameworks that stay stable as your distributed system scales and evolves.
Get StartedFrequently Asked Questions
Can I mock WebSocket connections with Playwright?
Playwright added WebSocket mocking via page.routeWebSocket(), letting you intercept and modify frames bidirectionally — ideal for testing real-time dashboards without a live socket server.
Does cy.intercept() work with both Fetch API and XMLHttpRequest?
Yes — since Cypress 6.0, cy.intercept() intercepts both Fetch and XHR natively, replacing cy.route() which only handled XHR. No polyfill or special configuration is required for Fetch.
Why do my Playwright route handlers not fire for some third-party scripts?
Third-party requests inside cross-origin iframes bypass page.route(). Register routes on BrowserContext instead — context.route() applies to all pages, frames, and workers in the session.
How do I simulate network latency in Playwright without changing the response?
Add a setTimeout inside your route handler before calling route.fulfill() or route.continue(). This simulates realistic latency, perfect for testing loading states and UI timeout edge cases.
What happens when multiple Playwright routes match the same URL?
Routes run in reverse registration order — last registered wins. Calling route.fallback() tries the next match. Without fallback, unmatched patterns go to the real network by default.
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.