Stubbing HTTP Requests in Puppeteer with page.route() for Deterministic Tests
Flaky end-to-end tests often depend on live APIs. Puppeteer's page.route() lets you intercept requests and fulfill them with fixed payloads, but broad interception can hide real failures and add latency.
08 Aug 2026, 09:26 UTC

The test that failed because the API had a bad day
Your end-to-end suite passes locally, then fails in CI because the staging API returned a 500, rate-limited your requests, or shipped a schema change overnight. You did not change the frontend code, yet the browser automation broke. The root cause is that the test depends on a live backend whose availability and contract you do not control.
Puppeteer's page.route() gives you a way out. It intercepts HTTP requests initiated by the page, pauses them in your Node.js process, and lets you decide whether to fulfill them with synthetic data, abort them, or forward them with modifications. Used narrowly, it removes external dependencies from the critical path of a test without pretending the network does not exist.
How request interception works
When you call page.route(pattern, handler), Puppeteer registers a handler for requests whose URL matches the pattern. The pattern can be a glob string such as '**/api/users' or a predicate function. Inside the handler you receive a route object (and the underlying request). You must eventually call exactly one of three methods:
route.fulfill()— respond immediately with a status code, headers, and body you control.route.abort()— fail the request at the network layer.route.continue()— let the request proceed, optionally with overridden headers or method.
The most common failure mode is forgetting to call any of them. The browser waits indefinitely for a response that never arrives, and the test times out with an opaque navigation error. If your handler throws an exception before calling one of these methods, the same hang can occur.
Handlers are scoped to the page object. A new tab or browser context starts with an empty routing table, so registration logic belongs in a shared helper rather than copy-pasted across specs.
A minimal stubbing example
The following script launches headless Chromium, stubs a single JSON endpoint, and reads the rendered result. Save it as stub-example.mjs and run it with node stub-example.mjs from your project root. It assumes Puppeteer is already listed in package.json; no elevated permissions are required beyond the ability to launch a local browser process.
import puppeteer from 'puppeteer';
const browser = await puppeteer.launch();
const page = await browser.newPage();
// Intercept only the endpoint under test
await page.route('**/api/users', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify([
{ id: 1, name: 'Stubbed User' }
]),
});
});
await page.goto('https://your-app.example.com/dashboard');
const name = await page.$eval(
'[data-testid="user-name"]',
(el) => el.textContent
);
console.log(name);
await browser.close();
Replace https://your-app.example.com/dashboard with the page you are testing and adjust the selector to match your markup. The script logs the text content of the first matching element. If the application renders the stub correctly, that log line contains the value you placed in the body field. If the page hangs instead, check that your handler always calls fulfill, abort, or continue before returning.
Setting contentType and status is not optional decoration. Client code frequently branches on HTTP status or relies on Content-Type to decide whether to parse a payload as JSON. A stub that omits these fields can pass the network layer but fail in application logic.
The cost of catching everything
It is tempting to register page.route('**/*', handler) and stub the whole world. Resist that instinct. Every intercepted request is paused in Node.js until your handler responds, so a catch-all route adds a round-trip through your test process for every image, script, and font. On a media-heavy page that overhead is measurable and often unnecessary.
Broad interception also masks real failures. If your production API changes a field name or starts returning 429, a catch-all stub will happily serve the old shape and the test will pass while the application is broken. Narrow patterns keep the blast radius small.
| Pattern | Practical effect | Main risk |
|---|---|---|
**/api/users | Stubs one endpoint; other traffic flows normally | Low overhead, minimal masking |
**/* | Intercepts every request including static assets | High latency; hides integration drift |
Limitations worth planning for
Service workers and aggressive HTTP caching can serve responses from disk before Puppeteer's interception layer sees them. If your application registers a service worker, a stubbed response may not appear in the page even though the route handler fired. Disabling cache in the browser context or clearing service workers before the test is a common mitigation, though the exact approach depends on your application's caching strategy.
Stubs are also only as honest as the person writing them. A partial JSON payload can hide client-side rendering bugs that only surface with a full, realistic response. When you stub, copy the shape of the real response as closely as possible.
What to check before you trust the suite
Puppeteer's API evolves. Before adopting these patterns, confirm the installed version in package.json and compare it against the page.route() documentation for that release. The handler signature and the lifecycle methods on the route object have changed across major versions.
To verify that interception is working as intended, run the same script against a local HTTP server that serves a known payload. Requests you did not intercept should reach that server and return real responses. Then measure page load time with and without a catch-all route to quantify the interception overhead in your own environment. If the numbers are unacceptable, narrow the pattern or let static assets pass through untouched.
Start with one endpoint, assert the rendered DOM, and expand only when the test earns it. Determinism is the goal; silence is not.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.