E
Elena Darevskaya
Guest
This article follows Playwright's request interception all the way down to the browser protocol. We'll look at how Playwright intercepts requests using
Our goal is not to replace frameworks with low-level APIs, but to understand what capabilities the browser actually exposes, and when working with underlying protocols can be useful.
All information and examples in this article are current as of September 2026. Browser automation protocols change quickly, so some details may change over time.
The runnable examples are available in the sample repo.
Playwright’s
Suppose our application is running at
However, using
Now calling
Now let's look at the underlying mechanism that lets Playwright intercept and modify requests.
How
In Chromium, Playwright implements request interception through the Chrome DevTools Protocol, also known as CDP.
CDP can be used for debugging, inspection, and browser automation. It is organized into domains such as
Before looking at how Playwright uses CDP's
A client — Playwright, Chrome DevTools, or our own script — can connect to Chromium and send it CDP commands, and Chromium responds using the same protocol.
For example, a client can enable request interception by sending a
Chromium responds with a message that has the same
When navigating to
The event payload looks something like this:
Chromium will keep the request paused until the client tells it how to handle the request: continue, fail or provide a specific response.
Playwright's implementation for
Playwright enables request interception with Fetch.enable:
It also listens to the
Then Playwright matches route and handler itself - Playwright adds additional capabilities like support for glob patterns, regular expressions,
See the runnable version of this example.
So far we've looked at the Chromium-specific implementation. CDP exists only in Chromium, so how is browser communication implemented in Firefox or WebKit?
If we're talking about Playwright, it ships its own patched builds for Firefox and WebKit. They allow Playwright to provide consistent automation features across browsers This is why Playwright requires its custom Firefox build rather than the standard Firefox release, and its own version of WebKit instead of Safari.
The Playwright API stays the same in all the browsers, while the underlying mechanism is different.
We have seen how Playwright uses CDP to communicate with Chromium. We can also communicate with Chromium directly without any framework.
First, we will need to connect to the browser, which requires a few steps:
Now we can send a command to Chromium. For example, let's enable request interception:
Once a request is made to a matching URL, Chromium will fire a
For the complete working example, see the repository.
Even this small example requires a lot of boilerplate code, but libraries like Puppeteer can handle browser communication for us. The same example will look like this:
With Puppeteer, we don't need to open a WebSocket, keep track of message ids or look up a target, and the resulting code is much shorter. See the complete Puppeteer example.
So far, we've seen that browser automation tools communicate with the browser differently depending on the browser. This makes automation harder: the same tool may need different implementations for Chromium, Firefox, and WebKit.
WebDriver BiDi is an effort to provide a common browser automation protocol across different engines.
It is currently published as a W3C Working Draft and is still under active development, but Chrome and Firefox already implement BiDi and it's used by some automation frameworks. For example, Puppeteer supports BiDi for both Chrome and Firefox and uses it by default for Firefox, and WebdriverIO uses it for browser automation alongside other protocols.
BiDi supports many browser automation features, including navigation and request interception.
This means we can implement our example from earlier with BiDi: intercept the request, fetch the response from localhost, and provide that response to the browser.
Just like CDP, BiDi also exchanges commands and events with the browser using WebSocket, but the interface itself is different. For example, for network interception we would use
We also need to subscribe to the event:
When the browser detects a matching request, it emits
However, BiDi still does not support everything that CDP does, for example, CPU throttling or APIs for tracing and profiling.
Another missing feature is
Most of the time, the high-level API is enough. The protocol layer becomes useful when we need a browser capability the framework does not expose, or when we want a much narrower browser tool.
Playwright exposes Chromium's protocol directly through
For example, CDP supports CPU throttling through
We still use Playwright for the test; CDP only adds the missing browser capability. A runnable version of this example is in the repository.
Protocol access can also be useful when building tools for agents.
Existing tools may return more than a task needs, and every extra field costs tokens once it's in the model's context. For example, Chrome DevTools MCP exposes
A narrower tool could expose:
and return only:
The filtering can happen against CDP network events before anything is sent to the model, so the list can be much shorter and require fewer tokens.
How much does this save? The sample repo that measures it: on one test page, a narrow CDP tool answers the question in approximately 9x fewer tokens than chrome-devtools-mcp's best filtered call.
We started with route interception in Playwright and saw how to implement browser interception through different protocols.
Along the way, we learned how browser automation differs between Chromium, Firefox, and WebKit, why Playwright needs patched browser builds, and why a cross-browser protocol like BiDi is needed.
This knowledge is useful beyond request interception: it helps us understand what automation frameworks work under the hood and when direct protocol access can be useful.
page.route() and how it works in different browsers. Then we'll see how to implement request interception using only Chromium's DevTools Protocol. And, finally, we will look at some examples where the knowledge about browser communication can help us in practice.Our goal is not to replace frameworks with low-level APIs, but to understand what capabilities the browser actually exposes, and when working with underlying protocols can be useful.
All information and examples in this article are current as of September 2026. Browser automation protocols change quickly, so some details may change over time.
The runnable examples are available in the sample repo.
Playwright’s
page.route() lets us intercept a request made by the browser and decide how to handle it. We can let the request continue, stop it, or provide a response ourselves. One way to use page.route() is to load a page from a domain that doesn't have a DNS or hosts file record.Suppose our application is running at
http://localhost:3000 but we want the browser to load it at the URL http://app.invalid. If we call page.goto('http://app.invalid') in our Playwright tests without any prior setup, the page will not load.However, using
page.route() we can make this domain load in our tests. It allows us to intercept the browser request to http://app.invalid, fetch the page we want to be loaded at this URL from localhost, and use that response to fulfill the original request:
Code:
await page.route('http://app.invalid/**', async route => {
const localUrl = route.request().url()
.replace(
'http://app.invalid',
'http://localhost:3000'
);
const response = await route.fetch({
url: localUrl,
});
await route.fulfill({
response,
});
});
Now calling
page.goto('http://app.invalid'); in our tests will actually load content at http://app.invalid, and if we will check the page origin, it will return the original URL we've navigated to:
Code:
await page.evaluate(() => location.origin);
// http://app.invalid
Now let's look at the underlying mechanism that lets Playwright intercept and modify requests.
How page.route() works in different browsers
Chromium
In Chromium, Playwright implements request interception through the Chrome DevTools Protocol, also known as CDP.
CDP can be used for debugging, inspection, and browser automation. It is organized into domains such as
Page, Runtime, Network, Performance and Fetch. Since we're talking about request interception, the Fetch domain will be the most interesting for us in this article.Before looking at how Playwright uses CDP's
Fetch domain, it helps to understand how communication through CDP works in general.A client — Playwright, Chrome DevTools, or our own script — can connect to Chromium and send it CDP commands, and Chromium responds using the same protocol.
For example, a client can enable request interception by sending a
Fetch.enable command to Chromium over CDP:
Code:
{
"id": 1,
"method": "Fetch.enable",
"params": {
"patterns": [{
"urlPattern": "http://app.invalid/*",
"requestStage": "Request"
}]
}
}
Chromium responds with a message that has the same
id. For Fetch.enable, a successful response may look as simple as this:
Code:
{
"id": 1,
"result": {}
}
When navigating to
http://app.invalid/ (the domain for which we've enabled interception), Chromium pauses the request and emits a Fetch.requestPaused event.The event payload looks something like this:
Code:
{
"method": "Fetch.requestPaused",
"params": {
"requestId": "...",
"request": {
"url": "http://app.invalid/",
"method": "GET",
"headers": {}
},
"resourceType": "Document"
}
}
Chromium will keep the request paused until the client tells it how to handle the request: continue, fail or provide a specific response.
Playwright's implementation for
page.route() in Chromium is built on CDP.Playwright enables request interception with Fetch.enable:
Code:
Fetch.enable({
handleAuthRequests: true,
patterns: [{
urlPattern: '*',
requestStage: 'Request'
}]
});
It also listens to the
Fetch.requestPaused event that Chromium emits when a request is intercepted:
Code:
eventsHelper.addEventListener(
session,
'Fetch.requestPaused',
this._onRequestPaused.bind(this, sessionInfo)
);
Then Playwright matches route and handler itself - Playwright adds additional capabilities like support for glob patterns, regular expressions,
URLPattern objects and predicates. It also supports multiple handlers for one route and fallback.See the runnable version of this example.
Firefox and Webkit
So far we've looked at the Chromium-specific implementation. CDP exists only in Chromium, so how is browser communication implemented in Firefox or WebKit?
If we're talking about Playwright, it ships its own patched builds for Firefox and WebKit. They allow Playwright to provide consistent automation features across browsers This is why Playwright requires its custom Firefox build rather than the standard Firefox release, and its own version of WebKit instead of Safari.
The Playwright API stays the same in all the browsers, while the underlying mechanism is different.
Connecting to Chromium without a framework
We have seen how Playwright uses CDP to communicate with Chromium. We can also communicate with Chromium directly without any framework.
First, we will need to connect to the browser, which requires a few steps:
Start Chromium with remote debugging enabled.
Request the available debugging targets, and select a target representing a page.
Connect to the target’s web socket.
Here is the example of how the code for it could look like:
Code:
// Start Chromium with remote debugging enabled.
const chrome = spawn(process.env.CHROME_PATH, [
'--headless=new',
'--remote-debugging-port=9222',
`--user-data-dir=${profileDir}`, // separate browser profile directory
'about:blank'
]);
// Request /json/list to see the available debugging targets, and select a target representing a page:
const response = await fetch(
'http://127.0.0.1:9222/json/list'
);
const targets = await response.json();
const target = targets.find(
target => target.type === 'page'
);
// The page target includes a webSocketDebuggerUrl.
// This is the URL of the socket we can use to send CDP commands to Chromium. Let's connect to it:
const ws = new WebSocket(
target.webSocketDebuggerUrl
);
await new Promise(resolve =>
ws.addEventListener('open', resolve, { once: true })
);
Now we can send a command to Chromium. For example, let's enable request interception:
Code:
ws.send(JSON.stringify({
id: 1,
method: 'Fetch.enable',
params: {
patterns: [{
urlPattern: 'http://app.invalid/*',
requestStage: 'Request'
}]
}
}));
Once a request is made to a matching URL, Chromium will fire a
Fetch.requestPaused event. We can listen to it and decide how to handle the intercepted request:
Code:
ws.addEventListener('message', async event => {
const message = JSON.parse(event.data);
if (message.method !== 'Fetch.requestPaused')
return;
const { requestId } = message.params;
ws.send(JSON.stringify({
id: 2,
method: 'Fetch.fulfillRequest',
params: {
requestId,
responseCode: 200,
body: Buffer.from('Hello').toString('base64')
}
}));
});
For the complete working example, see the repository.
Even this small example requires a lot of boilerplate code, but libraries like Puppeteer can handle browser communication for us. The same example will look like this:
Code:
const browser = await puppeteer.launch();
const page = await browser.newPage();
await page.setRequestInterception(true);
page.on('request', async request => {
if (!request.url().startsWith('http://app.invalid')) {
await request.continue();
return;
}
await request.respond({
status: 200,
body: 'Hello'
});
});
With Puppeteer, we don't need to open a WebSocket, keep track of message ids or look up a target, and the resulting code is much shorter. See the complete Puppeteer example.
WebDriver BiDi: Standardizing Browser Automation
So far, we've seen that browser automation tools communicate with the browser differently depending on the browser. This makes automation harder: the same tool may need different implementations for Chromium, Firefox, and WebKit.
WebDriver BiDi is an effort to provide a common browser automation protocol across different engines.
It is currently published as a W3C Working Draft and is still under active development, but Chrome and Firefox already implement BiDi and it's used by some automation frameworks. For example, Puppeteer supports BiDi for both Chrome and Firefox and uses it by default for Firefox, and WebdriverIO uses it for browser automation alongside other protocols.
BiDi supports many browser automation features, including navigation and request interception.
This means we can implement our example from earlier with BiDi: intercept the request, fetch the response from localhost, and provide that response to the browser.
Just like CDP, BiDi also exchanges commands and events with the browser using WebSocket, but the interface itself is different. For example, for network interception we would use
network.addIntercept - the analogue of CDP's Fetch.enable:
Code:
{
"id": 1,
"method": "network.addIntercept",
"params": {
"phases": ["beforeRequestSent"],
"urlPatterns": [{
"type": "pattern",
"protocol": "http",
"hostname": "app.invalid"
}]
}
}
We also need to subscribe to the event:
Code:
{
"id": 2,
"method": "session.subscribe",
"params": {
"events": ["network.beforeRequestSent"]
}
}
When the browser detects a matching request, it emits
network.beforeRequestSent, and we answer it with network.provideResponse, the analogue of Fetch.fulfillRequest:
Code:
{
"id": 3,
"method": "network.provideResponse",
"params": {
"request": "...",
"statusCode": 200,
"body": {
"type": "string",
"value": "Hello"
}
}
}
However, BiDi still does not support everything that CDP does, for example, CPU throttling or APIs for tracing and profiling.
Another missing feature is
DOM.getContentQuads, exposed by CDP. It returns quads - the four corner points of an element's content box. They describe an element accurately even when it has been transformed. Playwright uses quads when calculating where to click. The Playwright team has identified the lack of a similar BiDi API as one of the issues affecting full BiDi support for interactions.Why knowing the protocol layer is useful
Most of the time, the high-level API is enough. The protocol layer becomes useful when we need a browser capability the framework does not expose, or when we want a much narrower browser tool.
Extending Playwright with CDP
Playwright exposes Chromium's protocol directly through
CDPSession, so we can use Playwright normally and drop down to CDP only where needed.For example, CDP supports CPU throttling through
Emulation.setCPUThrottlingRate. Playwright has no dedicated API for it:
Code:
const cdp = await page.context().newCDPSession(page);
await cdp.send(
'Emulation.setCPUThrottlingRate',
{ rate: 4 }
);
await page.goto('https://example.com');
We still use Playwright for the test; CDP only adds the missing browser capability. A runnable version of this example is in the repository.
Building smaller browser tools for agents
Protocol access can also be useful when building tools for agents.
Existing tools may return more than a task needs, and every extra field costs tokens once it's in the model's context. For example, Chrome DevTools MCP exposes
list_network_requests, which can filter requests by resource type, but has no way to filter by URL or status. If an agent only needs to know whether /api/checkout failed, we will still need to get the full list, and the model will burn extra tokens.A narrower tool could expose:
Code:
getFailedRequests({
urlPattern: '/api/checkout'
})
and return only:
Code:
[
{
"url": "https://example.com/api/checkout",
"method": "POST",
"status": 500
}
]
The filtering can happen against CDP network events before anything is sent to the model, so the list can be much shorter and require fewer tokens.
How much does this save? The sample repo that measures it: on one test page, a narrow CDP tool answers the question in approximately 9x fewer tokens than chrome-devtools-mcp's best filtered call.
Conclusion
We started with route interception in Playwright and saw how to implement browser interception through different protocols.
Along the way, we learned how browser automation differs between Chromium, Firefox, and WebKit, why Playwright needs patched browser builds, and why a cross-browser protocol like BiDi is needed.
This knowledge is useful beyond request interception: it helps us understand what automation frameworks work under the hood and when direct protocol access can be useful.