setRequestInterception, a hosted mock, or bothCredit where due first: page.setRequestInterception(true) is a genuinely powerful primitive. It sits below the page at the network layer, sees every request the browser makes, and can abort, rewrite, or answer each one with request.respond(). It works offline and answers in microseconds. If the only consumer of your fake data is the page under test, and you're happy hand-writing response bodies, it can do the whole job.
But it has four sharp edges that a hosted mock URL smooths out β and the two tools compose beautifully, which is what most of this guide demonstrates:
continue()/respond()/abort() on every single request β miss one and the page hangs until timeout.request.respond() bodies are a second backend you maintain in test files. Twenty resources Γ list/detail/create/error variants adds up fast, and none of it is stateful β a POST in step 1 is invisible to the GET in step 2 unless you wire that state yourself.page.route.Below: a hosted mock for the data, interception for what it's best at β surgical rewrites and per-test flag injection. Every snippet was run verbatim before publishing (Puppeteer 25.9, bundled Chrome 152, Node 22; the parallel two-page run in Β§6 included).
curl -X POST https://mockbird.mockbird.workers.dev/api/projects \
-H 'content-type: application/json' -d '{"name":"pptr-demo","preset":"ecommerce"}'
That returns a project id and admin key, with products, orders, customers and reviews already seeded with realistic data β full CRUD, filtering, pagination, CORS on. Or create it in one click. The snippets below use the public demo project so you can paste-and-run them with nothing created at all.
If your app reads its API base from config/env, you don't need interception at all β this is the most robust setup, and it also covers SSR fetches that interception can never see:
const puppeteer = require('puppeteer');
const assert = require('node:assert');
(async () => {
const browser = await puppeteer.launch();
const page = await browser.newPage();
await page.goto('http://localhost:8137/?api=https://mockbird.mockbird.workers.dev/m/demo');
await page.waitForFunction(() =>
document.querySelector('#state').textContent.startsWith('loaded'));
const names = await page.$$eval('#list li', els => els.map(e => e.textContent));
assert.strictEqual(names.length, 5);
await browser.close();
})();
(The app under test here is a plain page that fetches {base}/products?limit=5 and renders an <li> per product β swap in your own app and selector.)
When you can't change the base URL, request.continue({ url }) swaps the URL below the page β the page still believes it called the production host, and the response comes from the mock (Mockbird sends Access-Control-Allow-Origin: *, so the cross-origin check passes):
const MOCK = 'https://mockbird.mockbird.workers.dev/m/demo';
await page.setRequestInterception(true);
page.on('request', req => {
const url = req.url();
if (url.startsWith('https://api.example.com')) {
req.continue({ url: url.replace('https://api.example.com', MOCK) });
} else {
req.continue(); // handle EVERY request, or the page hangs
}
});
This is interception doing the one thing only interception can do β and the response data, statefulness, filtering and pagination all come from the hosted mock instead of hand-written respond() bodies.
Every Mockbird endpoint honors simulation query params. The interception handler is a clean per-test injection point β the app code stays untouched:
// spinner/skeleton spec: every API call takes a real 2 seconds
page.on('request', req => {
const u = new URL(req.url());
if (u.hostname === 'mockbird.mockbird.workers.dev') {
u.searchParams.set('mock_delay', '2000');
req.continue({ url: u.toString() });
} else req.continue();
});
await page.goto(APP);
assert.strictEqual(await page.$eval('#state', el => el.textContent), 'loading...');
// β¦assert the skeleton, then wait for data
Swap the param for the error-matrix spec: u.searchParams.set('mock_status', '500') returns a real 500 with a JSON body (any 400β599 works), and your error UI renders exactly as it would in production. Full recipes: loading & error states guide.
mock_seq scripts an exact status sequence β deterministic, unlike random chaos. First call 500, every call after that 200:
const key = 'run-' + Date.now(); // fresh counter per test run
page.on('request', req => {
const u = new URL(req.url());
if (u.hostname === 'mockbird.mockbird.workers.dev') {
u.searchParams.set('mock_seq', '500,200');
u.searchParams.set('mock_seq_key', key);
req.continue({ url: u.toString() });
} else req.continue();
});
Our verification run's retry loop observed exactly [500, 200] and got the data on attempt two. The counter is server-side per key β a stale key from a previous run would replay from wherever it left off, hence the timestamp. For random flakiness use ?mock_chaos=0.4 (chaos-failed writes are not applied, so pointing retry logic at it is safe).
Empty list, edge cases, bug repro: save each state as a named snapshot once, then any request can pin itself to a snapshot with one header. Live data is untouched; two pages read different states of the same project at the same time. Setup (once):
ADMIN='x-admin-key: YOUR_ADMIN_KEY'
B=https://mockbird.mockbird.workers.dev/api/projects/YOUR_ID
curl -X POST $B/snapshots -H "$ADMIN" -H 'content-type: application/json' -d '{"name":"baseline"}'
curl -X POST $B/resources/products -H "$ADMIN" -H 'content-type: application/json' -d '{"seed":0}'
curl -X POST $B/snapshots -H "$ADMIN" -H 'content-type: application/json' -d '{"name":"empty"}'
curl -X POST $B/snapshots/baseline/restore -H "$ADMIN"
Then in the test, the pinned page adds the header via interception:
async function preparePage(browser, snapshot) {
const page = await browser.newPage();
if (snapshot) {
await page.setRequestInterception(true);
page.on('request', req => {
if (req.url().startsWith(MOCK)) {
req.continue({ headers: { ...req.headers(), 'X-Mockbird-Snapshot': snapshot } });
} else req.continue();
});
}
return page;
}
const liveP = await preparePage(browser, null); // live data
const emptyP = await preparePage(browser, 'empty'); // pinned to "empty"
await Promise.all([runApp(liveP), runApp(emptyP)]); // same project, same moment
// liveP renders 5 products; emptyP renders the empty state
That exact parallel run is part of our pre-publish verification. Writes while pinned return 405 with a restore hint, so a test can't corrupt a scenario by accident.
waitForFunction stalls in backgrounded tabs. The default polling: 'raf' rides requestAnimationFrame, which Chrome throttles to zero in non-visible pages β so the multi-page test in Β§6 times out even though the page finished loading. Pass { polling: 'mutation' } (or a millisecond interval) for any wait that must fire in a background page.--no-sandbox. On distros that restrict unprivileged user namespaces, puppeteer.launch() dies with "No usable sandbox!" β puppeteer.launch({ args: ['--no-sandbox'] }) is the standard CI workaround (understand the tradeoff before using it outside CI).setRequestInterception + respond() | Mockbird (hosted) | Both (this guide) | |
|---|---|---|---|
| Works offline / airgapped CI | β | β needs network | β |
| Response latency | ~0 ms | real network RTT | real RTT |
| Arbitrary JS per response | β | partial β templated custom routes | β via respond() where needed |
| Seeded, realistic, stateful CRUD data | hand-written, stateless | β | β |
| Browser cache stays enabled | β (disabled by interception) | β (no interception needed, Β§2) | β on intercepted pages |
| SSR / Node-side fetches | β | β any process, any language | β via base URL |
| Same data for curl / teammates / QA links | β | β it's a URL | β |
| Parallel data scenarios | DIY state machine | β snapshot pinning, one header | β |
If your test surface is browser-only, the backend exists, and you need a handful of stubs β plain interception (or MSW, whose service-worker mode also works under Puppeteer) is a fine answer, and this table says so.
npm i puppeteer
curl -X POST https://mockbird.mockbird.workers.dev/api/projects \
-H 'content-type: application/json' -d '{"name":"pptr-demo","preset":"ecommerce"}'
β¦or skip the terminal: create the same seeded project in one click. Free, no signup required β the project is anonymous and claimable later. Full docs.
Using Playwright instead (or as well)? The Playwright guide covers the same patterns with page.route, including the route.fallback composition trick. Stuck on Selenium? The Selenium guide does these patterns with the new BiDi network API (selenium-wire is archived). Component stories rather than e2e? Storybook. Running in CI? Ephemeral project per GitHub Actions run.
Environment used for verification: Puppeteer 25.9.0, bundled Chrome for Testing 152, Node 22, run against the live demo project and a scratch project on 2 Sep 2026 β including the Β§6 parallel two-page run (twice) and both gotchas reproduced. If a snippet here doesn't work, that's a bug: tell us.