Minimal examples of intercepting and modifying browser requests using the Chrome DevTools Protocol (CDP) — the same mechanism Playwright uses under the hood for page.route().
No Playwright. No CDP client library. Just a WebSocket and raw JSON.
Chrome's Fetch domain lets you pause requests before they hit the network and decide what to do with them:
Page.navigate(url)
│
▼
Chrome loader
│
▼
Fetch.requestPaused ← Chrome tells you about the request
│
┌────┴────┐
│ │
fulfillRequest continueRequest
│ │
▼ ▼
local network
response
Three CDP commands cover both cases:
| Command | What it does |
|---|---|
Fetch.enable |
Tell Chrome which requests to pause and when |
Fetch.fulfillRequest |
Provide the response yourself — nothing goes to the network |
Fetch.continueRequest |
Let the request proceed, optionally with modified headers/url/body |
src/intercept-fulfill.mjs
Navigates to a hostname that does not exist. Before Chrome even attempts DNS, we intercept the request and return a local HTML page. The tab loads successfully at the nonexistent URL.
Key calls:
await cdp.send('Fetch.enable', {
patterns: [{ urlPattern: 'https://app.this-does-not-exist.example/', requestStage: 'Request' }],
});
// Chrome fires Fetch.requestPaused — we answer:
cdp.send('Fetch.fulfillRequest', {
requestId,
responseCode: 200,
responseHeaders: [{ name: 'Content-Type', value: 'text/html; charset=utf-8' }],
body: Buffer.from('<h1>Served locally</h1>').toString('base64'),
});src/intercept-modify.mjs
Navigates to https://httpbun.com/get. We intercept the request, inject X-Intercepted: true into its headers, then call continueRequest. Chrome sends the modified request to the network. The httpbun response echoes the injected header back, proving Chrome sent it.
Key calls:
await cdp.send('Fetch.enable', {
patterns: [{ urlPattern: 'https://httpbun.com/get', requestStage: 'Request', resourceType: 'Document' }],
});
// Chrome fires Fetch.requestPaused — we answer:
cdp.send('Fetch.continueRequest', {
requestId,
headers: [...existingHeaders, { name: 'X-Intercepted', value: 'true' }],
});npm installnpm run chromeThis launches Chrome with --remote-debugging-port=9222, creating a temporary profile directory automatically. If Chrome is already running on port 9222 it exits immediately.
node demo.mjs fulfill # serves local HTML at a nonexistent URL
node demo.mjs modify # injects a header into a real request
# or via npm
npm run fulfill
npm run modifydemo.mjs starts Chrome automatically if it isn't running yet, then runs the example.
src/
cdp.mjs raw WebSocket CDP transport (connect, send, on)
intercept-fulfill.mjs intercept + fulfill with local response
intercept-modify.mjs intercept + modify headers + continue to network
scripts/
start-chrome.mjs launches Chrome with remote debugging
The transport is intentionally minimal — it is not a library:
const cdp = await connect('http://localhost:9222');
await cdp.send('Fetch.enable', { patterns: [...] });
cdp.on('Fetch.requestPaused', ({ requestId, request }) => { ... });connect() creates a new Chrome tab via PUT /json/new, opens its WebSocket debugger URL, and returns { send, on }. That is all it does.