This repo contains examples of intercepting HTTP responses in both Puppeteer & Playwright.
- 🔧 Creates a new Chrome-Devtools-Protocol (CDP) session in Puppeteer or Playwright.
- 🔨 Enable
Fetchdomain to let us substitute browser's network layer with our own code. - 👀 Pause every page response and check its
content-typeheader to matchpdfandxmltypes. - ⏩ If the
content-typeis not what we are looking for, resume the request without any change. - 🎯 If the
content-typeis what we're looking for (pdforxml), add acontent-disposition: attachmentresponse header to make the browser download the file instead of opening it in Chromium's built-in viewers.
A visual overview
Puppeteer's request interception can continue, abort, or answer a request with a response you build yourself, but it can't modify the response that actually comes back from the server. The CDP Fetch domain can. There may be multiple scenarios where you need to modify either the response body or response headers for crawling or testing. As an example, you may want Chromium to download PDF and XML content-type responses instead of opening them in the built-in viewers.
This is still how Chrome behaves, including in headless mode. Without the examples' interception (tested with Chrome 155):
| Browser mode | XML | |
|---|---|---|
| Headful | Opened in the PDF viewer | Rendered in the XML viewer |
Headless (Puppeteer's default headless: true) |
Opened in the PDF viewer | Rendered in the XML viewer |
chrome-headless-shell (Puppeteer's headless: 'shell', Playwright's default headless Chromium) |
Downloaded | Rendered in the XML viewer |
Playwright has since added a built-in way to do this; see Playwright's built-in alternative below.
PDF response (content-type: application/pdf) is opened in Chromium
XML response (content-type: text/xml) is also rendered in Chromium
Make Chromium download the files. This can be done by adding a content-disposition: attachment header to the response.
I've setup a test site with links to both the PDF file and XML file.
https://pdf-xml-download-test.vercel.app/
- Node.js 22.12 or later
$ npm installPuppeteer downloads its own Chrome during npm install. Playwright needs its Chromium installed separately:
$ npx playwright install chromiumDepending on whether you want to use Puppeteer or Playwright, run one of the following commands.
$ npm run puppeteer$ npm run playwrightA browser window opens the test site. Click the PDF and XML links, and the files are saved to the downloads folder instead of being opened in the browser. Close the browser window to exit.
- Codes for Puppeteer and Playwright are almost identical. They have subtle differences in creating a new CDP session and in saving downloads (Playwright deletes downloaded files when the browser closes, so the Playwright example copies them with
download.saveAs()), but all other code are pretty much the same. - Chromium is the only browser that will work with this example. Using Firefox or Webkit browsers will throw errors since they don't support CDP.
Fetch.enableonly pausesDocumentrequests (pages and frames) at theResponsestage, so images, scripts, and stylesheets load without a round trip through Node.js. You can narrow it down further withurlPattern.- If the response already has a
content-dispositionheader (e.g.inline), it is replaced withcontent-disposition: attachment. - If you only need to change headers, the experimental
Fetch.continueResponsemethod can modify them without loading the body into Node.js first. This example usesFetch.getResponseBodyandFetch.fulfillRequestso you can modify the body too. - If you only need PDFs downloaded, Chrome's
plugins.always_open_pdf_externallypreference does that without any interception. It has to be set in the browser profile, though, and it doesn't cover XML or any other content type. - Call
Fetch.disableto stop intercepting requests.
Newer versions of Playwright can modify responses without CDP by fetching the original response with route.fetch() and passing it to route.fulfill() with your changes. Unlike the CDP approach, page.route() isn't tied to Chromium.
await page.route('**/*', async (route) => {
if (route.request().resourceType() !== 'document') {
return route.continue();
}
const response = await route.fetch();
const headers = response.headers();
const contentType = (headers['content-type'] ?? '').split(';')[0].trim().toLowerCase();
if (['application/pdf', 'application/xml', 'text/xml'].includes(contentType)) {
headers['content-disposition'] = 'attachment';
}
await route.fulfill({ response, headers });
});In case you're confused what the passed object in requestPaused looks like, a log is attached below. The object contains both request and response information. The response body should be retrieved separately using Fetch.getResponseBody.
Code
client.on('Fetch.requestPaused', (reqEvent) => {
console.log(reqEvent);
});Console Output
{
requestId: 'interception-job-14.0',
request: {
url: 'https://pdf-xml-download-test.vercel.app/api/file/pdf',
method: 'GET',
headers: {
Accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7',
'Accept-Language': 'en-US,en;q=0.9',
Referer: 'https://pdf-xml-download-test.vercel.app/',
'Upgrade-Insecure-Requests': '1',
'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/155.0.0.0 Safari/537.36',
'sec-ch-ua': '"Chromium";v="155", "Not(A:Brand";v="24"',
'sec-ch-ua-mobile': '?0',
'sec-ch-ua-platform': '"Linux"'
},
initialPriority: 'VeryHigh',
referrerPolicy: 'strict-origin-when-cross-origin'
},
frameId: 'A8363E2E3AE9AB3DA80B3E7DB2305AC6',
resourceType: 'Document',
responseStatusCode: 200,
responseStatusText: '',
responseHeaders: [
{ name: 'age', value: '0' },
{
name: 'cache-control',
value: 'public, max-age=0, must-revalidate'
},
{ name: 'content-type', value: 'application/pdf' },
{ name: 'date', value: 'Fri, 16 Oct 2026 11:27:40 GMT' },
{ name: 'server', value: 'Vercel' },
{
name: 'strict-transport-security',
value: 'max-age=63072000; includeSubDomains; preload'
},
{ name: 'x-nextjs-page', value: '/api/file/pdf' },
{ name: 'x-vercel-cache', value: 'MISS' },
{
name: 'x-vercel-id',
value: 'iad1::sfo1::jnwl7-1792150060359-d1b1cc5b0386'
},
{ name: 'content-length', value: '13264' }
],
networkId: '671349D5418CDFEDFE7F1897C48D94A6'
}
