Headless Browser Request Interception for API Mocking
Mock APIs without touching live servers, keeping your tests fast and reliable.

Request interception is the reason a headless test suite can run a thousand times a day without ever touching a real server, and it's the single technique that separates a test suite people trust from one they quietly disable. Once you can grab an outbound HTTP call before it leaves the browser and decide what comes back, your tests stop depending on things you don't control: rate limits, staging databases, some third-party payment API having a bad Tuesday.
That's the whole premise of this piece. Headless browsers are supposed to give you fast, repeatable tests, but "fast and repeatable" falls apart the moment your test suite is at the mercy of a live API that rate-limits you mid-run, or a backend where some user's status flag flipped overnight and broke an assertion that passed yesterday. Flaky tests have a real cost: engineers stop trusting red builds, start re-running failures until they go green, and eventually mute the exact tests meant to catch regressions. Smarter retry logic addresses symptoms at best. Removing the live network dependency for every case where the test doesn't actually need it addresses the cause, and interception is the mechanism that makes that possible.
How headless browsers see and control network traffic at the protocol level
A headless browser runs the real browser engine, minus the window, making real HTTP calls through the same network stack a human's browser would use. Interception hooks into the actual layer where the browser decides to send bytes over the wire, a layer beneath where test-code-level request faking operates.
For years, that layer was the Chrome DevTools Protocol, or CDP. CDP works by opening a WebSocket connection to the browser process and exchanging JSON messages organized into domains: Network, Page, Runtime, Input. Interception lives in the Network domain, and it covers HTTP/1.1, HTTP/2, HTTP/3, and WebSocket traffic, all without needing a proxy sitting in the middle or any PKI certificate juggling. Playwright and Puppeteer both run on CDP under the hood; when your test script calls page.goto(), what's actually happening is a CDP command getting dispatched to the browser process.
Here's the catch. CDP is Chromium's protocol, full stop. It never runs on Firefox, and code written against a specific version of DevTools can quietly break the next time Chrome bumps that version number, because you're coupled to an implementation detail rather than a stable spec. That's a brittle place to build a test suite that's supposed to run everywhere your users actually browse.
WebDriver BiDi is the cross-browser answer, and it's a genuinely different design. It was built jointly by the Selenium project and browser vendors as a bidirectional protocol, adding a persistent WebSocket channel on top of standard WebDriver so a script can stream browser events and react to them in real time instead of polling. Its Network module exposes addIntercept, which takes a phase and a URL pattern; the browser pauses any matching request and hands control to your handler, which can let it continue, force it to fail, or mock it outright. Chrome, Edge, and Firefox all implement BiDi natively, which means the same interception code runs across all three without a Chromium-only escape hatch bolted on.
Every framework covered in the rest of this piece is really just a wrapper around one of these two layers. Knowing which one a tool sits on tells you almost everything about what it can and can't do.
The four things you can do once you intercept a request
Interception isn't a light switch. The moment you pause a request, the page is sitting there waiting, and if you never resolve that request, the page hangs. Full stop, no error message, just a spinner spinning into eternity while your CI job eats through its timeout budget. So every intercepted request needs one of four resolutions.
Continue passes the request through unchanged. You're not altering behavior here, just watching. This is the interception equivalent of eavesdropping: useful when you want to assert that your app sent the right payload without touching what comes back.
Fulfill is the one that gets used constantly. You substitute a synthetic response entirely, the real server never even sees the request, and the browser gets back whatever JSON body, status code, and headers you hand it. This is how a test can assert "when the API returns a 500, the UI shows this exact error banner" without ever provoking a real 500 from a real server. The response isn't limited to JSON either; you can fulfill with plain text, binary payloads, whatever content type the scenario calls for.
Abort cancels the request before it goes anywhere. Analytics beacons, tracking pixels, ad network calls, giant font and image downloads that add three seconds of latency but contribute nothing to what you're asserting: all reasonable candidates for the chopping block.
Modify is the hybrid: let the real request go out, get the real response back, then mutate a field or two before handing it to the browser. Say you trust 95% of a staging API's response but need to force a feature flag to true, one that staging never actually returns. Modify gets you real fidelity everywhere except the one variable you're deliberately testing.
One rule sits underneath all four, regardless of framework or protocol: resolve every intercepted request, every time. And scope your handlers tightly, to the exact URLs your test cares about, because a loosely matched pattern will happily swallow traffic from some unrelated part of the app and contaminate a test that has nothing to do with it. Clean up your handlers in teardown. Skipping that step is how test seventeen starts failing because of a mock registered in test three.
Playwright's interception API and why it dominates current adoption
Playwright, built by Microsoft, targets Chromium, Firefox, and WebKit from one API, with auto-waiting baked in and network interception as a first-class feature rather than a bolt-on.
The core surface is small and reads the way you'd hope it would. page.route(pattern, handler) registers an interceptor; any matching request gets paused before it reaches the server. From there, route.fulfill({ status, body, headers }) returns a synthetic response without the server ever being contacted, route.abort() cancels the request outright, and the modify pattern combines page.request.fetch(route.request()) with a mutation step and a final route.fulfill() call to hand back the altered version. XHR and Fetch both get caught the same way, no separate wiring required depending on which mechanism the app happens to use.
The adoption numbers back up why this API gets reached for so often. As of mid-2026, the playwright npm package pulls roughly 78 million weekly downloads against about 7.4 million for Cypress, a gap north of ten to one. The State of JS 2024 survey had Playwright ahead of Cypress on both satisfaction and usage, and Playwright's GitHub star count has crossed 78,000. Among QA professionals in recent developer surveys, close to half report using Playwright day to day, while Cypress sits around 14% and Selenium has slid to roughly 22%.
The architecture explains a gap that size: Playwright runs out-of-process, so interception happens down at the network stack rather than inside the page's own JavaScript context. That distinction matters enormously the moment you're dealing with cross-origin requests or multiple pages open at once, scenarios where in-page interception tends to get tangled.
Cypress and Puppeteer: where their interception models fit and where they fall short
Cypress's cy.intercept() reads like plain English, and that's not nothing. Stub a response, wait on a specific request, assert against the request payload, all inside one chainable command that a junior engineer can understand on first read. It's also thoroughly documented and widely taught, so teams already standardized on Cypress have no real reason to bolt on a second framework just for mocking.
But Cypress runs its tests inside the browser process itself. That buys direct DOM access, which is genuinely convenient, though it costs flexibility once you need multi-browser coverage or cross-platform automation. There's also a real performance gap: Cypress runs roughly four times slower than Playwright during API testing, a difference that barely registers on a ten-test suite but becomes very noticeable once you're intercepting hundreds of calls across a full regression run. Cypress fits well for frontend-heavy teams doing single-browser UI work. It fits less well once you're trying to run a large suite in parallel across multiple browsers in CI.
Puppeteer's page.setRequestInterception() covers the basics fine, request interception and selective on-the-fly modification. Where it gets awkward is anything more elaborate, like rewriting response bodies or simulating specific network conditions, where Playwright's page.route() tends to need noticeably less setup for the same result. Puppeteer is also Chromium-only by design, so a team that suddenly needs Firefox or WebKit coverage is stuck adding a second framework rather than extending the one they have. It's worth noting Playwright functions as something close to a superset for interception use cases; teams that outgrow Puppeteer's ceiling tend to land there rather than somewhere else.
Selenium's story here runs through WebDriver BiDi. BiDi's network module brings addIntercept to Selenium, using the same continue-fail-mock model described earlier. WebdriverIO v9 added automatic BiDi support across major browser sessions, which finally killed the need for CDP workarounds and third-party interception libraries that Selenium users used to have to bolt on. Full cross-browser BiDi support is still filling in; cloud vendors like SauceLabs, BrowserStack, and Selenium Grid v4+ cover the common cases, though some of the more advanced primitives are still waiting on browser-side implementation. If a team already has years of Selenium suites and doesn't want to rewrite them from scratch, BiDi gives them a real path to cross-browser mocking without starting over.
Recording real sessions as HAR files and replaying them as mocks
Hand-writing every fixture works fine for a small app and becomes a maintenance sinkhole the moment your API surface grows. Nested objects get added, pagination metadata shifts, some edge-case field nobody remembered to stub quietly falls out of sync with what production actually returns.
A HAR file sidesteps that by recording the real thing. It's a JSON document capturing every request and response a page made during a session: URLs, methods, headers, status codes, timings, and full response bodies. Browsers have let you export HAR from DevTools for a long time, so this isn't new technology; what's new is wiring it directly into test replay.
Playwright's workflow is straightforward. Record a session with --save-har on the CLI, optionally narrowing it to just API calls with --save-har-glob so you're not also capturing every stylesheet and font file. Replay happens through routeFromHAR(), which matches on URL and HTTP method strictly, and for POST requests also matches the payload strictly. If multiple recorded entries match a request, Playwright picks whichever has the most matching headers.
The payoff shows up in three places. Speed, because a replayed response comes off disk in milliseconds instead of waiting on a network round-trip. Determinism, because the exact same bytes come back every single run, so a test failure actually means something broke rather than the backend having a mood swing. And offline capability, since a CI runner can execute the entire suite without reaching a single external service. There's a quieter benefit too: HAR captures the real shape of a live response, including fields a hand-written stub would likely miss, which matters a lot for APIs with schemas that shift or grow complexity over time.
What goes wrong with HAR files and how to handle it
A HAR file is a photograph, not a living document. It freezes one session at one moment, and that creates problems the longer it sits around unmaintained.
Start with the obvious one: a HAR recording contains whatever the session actually sent, which means auth tokens, session cookies, emails, and user IDs sitting right there in the URLs and headers, exactly as they appeared. Commit an unscrubbed HAR file to source control and you've effectively published a credential leak with a JSON file extension. That's not a hypothetical risk; it's baked into what a HAR is by definition.
There's also the problem of dynamic values baked into the recording. A user ID in a URL, a CSRF token, a timestamp: none of these are guaranteed to line up when the test replays against a different environment or a differently seeded test user. And re-recording doesn't merge, it replaces; run the record command again and the old HAR file is simply gone, with no accumulation of entries. If a suite needs several independent flows recorded separately, each one needs its own file, which multiplies the maintenance burden fast.
The ecosystem has responded with a few patterns. Third-party tools like playwright-advanced-har and playwright-network-cache add scrubbing, anonymization, and merge-friendly storage on top of Playwright's base HAR support. Some teams write custom post-processing scripts that strip or replace dynamic values before anything gets committed. Others treat HAR files less like source code and more like a build artifact: generated fresh from a clean backend session in a controlled environment, never hand-edited, and regenerated whenever drift creeps in.
So where does that leave the decision? HAR replay earns its keep on stable, read-heavy API flows where the response shape barely changes. For anything tied to session-specific data or values that shift constantly, falling back to explicit route.fulfill() stubs with fixtures you control by hand tends to be the more predictable choice, even if it means writing that fixture yourself.
Mock Service Worker as an interception layer that lives outside the test framework
Every approach covered so far intercepts from the outside in: the test runner reaches into the browser and redirects traffic before the app ever knows a request happened. Mock Service Worker, MSW for short, intercepts at the network boundary itself, using the browser's own Service Worker API, which means the interception layer lives independent of whichever test framework happens to be running.
Practically, that means the application code never changes. It calls fetch('/api/users') exactly the way it would in production, MSW catches that call on the wire, and hands back a mock response. No monkey-patching fetch, no swapping in a fake axios adapter, nothing rewritten in the app just to make tests possible.
Because the handlers live outside any single test framework, the same mock definitions can run in a Vitest unit test, a Playwright end-to-end suite, a Storybook demo, and a local dev environment, all without touching the application code differently between contexts. It handles both REST and GraphQL, regardless of which HTTP client the app uses under the hood. As of a 2023 TestJS Summit talk, MSW had crossed 90,000 dependent projects on GitHub and had become something close to the default mocking approach across React, Vue, Svelte, Angular, and Node.js projects; the number has likely only grown since.
The tradeoff is setup. MSW needs a Service Worker registered in the browser, which is an extra step Playwright-native interception simply doesn't ask for. Playwright's page.route() needs zero in-app changes, but it only works inside the scope of that one test runner. MSW earns its keep specifically when a team wants one source of truth for mocks shared across unit, integration, and end-to-end layers, or when the same mocks power a Storybook demo and duplicating them per test layer becomes the bigger headache. It also buys a kind of fidelity the other approaches can't: because the app's real fetch call goes through all its actual middleware and error handling before MSW ever answers it, you're testing more of the real code path.
Error simulation and edge-case testing that only interception makes practical
Here's the thing about error states: they're exactly the scenarios a live backend won't reliably produce on demand, and they're exactly the scenarios your app absolutely has to handle correctly. A payment gateway timing out mid-transaction. A 429 rate-limit response arriving three requests into a retry loop. A malformed JSON body where a field silently changed from a number to a string. Good luck asking a production API to reproduce any of that on a schedule that matches your CI pipeline.
Interception makes all of it trivial to construct, because fulfill() doesn't care whether the scenario is realistic to trigger naturally, it only cares that you told it what to return. Want to test a 503 from a payment provider? Fulfill that route with a 503 and whatever error body the provider's documentation says it sends. Want to check that your retry logic backs off correctly on a 429? Fulfill the first two attempts with 429 and the third with a 200, and watch whether your app actually waits the way it's supposed to.
This is also where the abort resolution earns its place outside of the tidy "block analytics" use case. Aborting a request entirely, rather than fulfilling it with an error, simulates the network dying mid-flight, which is a meaningfully different failure mode than the server responding with bad news. An app that handles a 500 gracefully might still hang forever on a connection that just vanishes, and you'd never know that without a way to force that exact condition.
Watching error handling actually fire, on command, every single time the suite runs, carries far more weight than hoping it works. That's the throughline connecting everything in this piece: interception is the mechanism that turns "the tests are green today" into a statement you can actually trust tomorrow.

