How to Use Chrome DevTools Protocol to Capture a Website Screenshot
Capture a website image with Chrome DevTools Protocol: connect to Chrome, call Page.captureScreenshot, decode its base64 result, and save the file.
Use Chrome DevTools Protocol (CDP) Page.captureScreenshot. It returns a base64-encoded image in the data field; decode that string and write the bytes to a file. CDP supports PNG, JPEG, and WebP, plus options for JPEG quality, a clipped rectangle, and capturing beyond the viewport. The protocol method performs the capture; your client still needs to connect to a Chrome page target, navigate, wait for the desired content, and save the result.
This guide uses Node.js and Puppeteer to connect to Chrome and send the low-level CDP command directly. Puppeteer also provides a higher-level screenshot API when you do not need to call the protocol method yourself.
1. Set up Node.js and Chrome
Use a supported Node.js installation and install Puppeteer in a new project. Puppeteer normally downloads a compatible browser as part of its installation. If your environment supplies Chrome separately, configure Puppeteer to use that executable.
mkdir cdp-screenshot
cd cdp-screenshot
npm init -y
npm install puppeteer
Save the following as screenshot.mjs. Run it with node screenshot.mjs https://example.com. It uses an explicit viewport, navigates, waits for the page load event, sends Page.captureScreenshot, decodes the returned data, and writes a PNG.
import puppeteer from 'puppeteer';
import { writeFile } from 'node:fs/promises';
const url = process.argv[2] ?? 'https://example.com';
const outputPath = process.argv[3] ?? 'screenshot.png';
const browser = await puppeteer.launch({ headless: true });
try {
const page = await browser.newPage();
await page.setViewport({ width: 1440, height: 900, deviceScaleFactor: 1 });
await page.goto(url, { waitUntil: 'load', timeout: 60_000 });
// Replace this with a site-specific readiness condition when needed.
await page.waitForSelector('body');
const cdp = await page.createCDPSession();
await cdp.send('Page.enable');
const { data } = await cdp.send('Page.captureScreenshot', {
format: 'png',
captureBeyondViewport: false
});
await writeFile(outputPath, Buffer.from(data, 'base64'));
console.log(`Saved ${outputPath}`);
await cdp.detach();
} finally {
await browser.close();
}
For a page that renders important content after the load event, replace waitForSelector('body') with a selector or application-specific condition that signals the content is ready. A body element alone does not mean that images, web fonts, client-rendered data, or animations have finished.
2. Choose the capture area and format
The screenshot method takes capture parameters. Set the mode deliberately; viewport size is configured by the surrounding browser workflow, not inferred by Page.captureScreenshot.
| Need | CDP option or setup | Notes |
|---|---|---|
| What is currently visible | Omit clip; leave captureBeyondViewport false |
This is the normal viewport capture. The documented default for captureBeyondViewport is false. |
| A specific rectangle | clip |
Provide an x, y, width, and height rectangle, plus scale. Coordinates are relative to the page’s layout viewport. Check that the rectangle fits the intended content. |
| Content outside the viewport | captureBeyondViewport: true |
Use with a clip that reaches beyond the current viewport when appropriate. The protocol reference does not promise a universal maximum page height. |
| PNG output | format: 'png' |
Good when preserving crisp edges or transparency matters; files may be larger than lossy formats. |
| JPEG output | format: 'jpeg', optionally quality |
Quality is relevant to JPEG. Use a matching filename extension. |
| WebP output | format: 'webp' |
Confirm that downstream tools accept WebP before choosing it. |
The protocol documents fromSurface with a default of true. Usually you can leave it at that default. Its exact impact can depend on the capture context, so consult the protocol reference if you have a specialized rendering workflow.
Capture a clipped rectangle
After navigation and CDP session setup in the script above, replace the capture call with this example. It captures a 700 by 450 CSS-pixel rectangle beginning at the page origin and scales that clip by one.
const { data } = await cdp.send('Page.captureScreenshot', {
format: 'png',
clip: { x: 0, y: 0, width: 700, height: 450, scale: 1 },
captureBeyondViewport: true
});
await writeFile('region.png', Buffer.from(data, 'base64'));
For a different region, measure or calculate its coordinates in the page’s coordinate space. If you want a particular element rather than a rectangle, a higher-level helper can scroll it into view and handle its bounds; see the Puppeteer section below.
Capture beyond the viewport
To request content outside the visible viewport, set captureBeyondViewport to true. This parameter is not a guarantee that every arbitrarily tall document will fit into one image. Page size, browser version, memory, and the content itself can affect results. For particularly long pages, consider capturing sections and combining them in your application, or use a wrapper’s full-page facility and verify the output.
const { data } = await cdp.send('Page.captureScreenshot', {
format: 'png',
captureBeyondViewport: true
});
await writeFile('beyond-viewport.png', Buffer.from(data, 'base64'));
3. Wait for the right page state
Navigation completion and visual readiness are different. A page may continue fetching data, loading lazy images, swapping web fonts, or showing an overlay after navigation resolves. Choose a readiness signal tied to the page you are capturing.
- Known content selector: wait for a heading, result container, or other element that appears when the content is ready.
- Application state: if you control the site, expose a reliable ready signal and wait for it.
- Network idle: Puppeteer’s
networkidle2is an available navigation wait condition and can be a useful starting point. It is not a universal guarantee: analytics, polling, and persistent connections can prevent idle, while a quiet network does not prove that every visual element is ready. - Short delay: use only when a site has a known delayed visual transition and no better signal. A fixed sleep adds time and can still be too short or unnecessarily long.
For repeatable captures, also set the viewport and device scale factor. If the page animates or changes dynamically, decide whether to wait for a stable state or disable the relevant behavior in the page or capture workflow. Inspect output images for cookie banners, chat widgets, popups, unloaded images, and other overlays rather than assuming the wait condition removed them.
4. Use Puppeteer’s screenshot wrapper when it fits
If you need a file path, full-page capture, or element screenshot without handling base64 yourself, Puppeteer’s Page.screenshot() is simpler. Its screenshot options include path, type, quality, fullPage, and clip. An element handle also has a screenshot method; Puppeteer documents that it scrolls a hidden element into view by default.
import puppeteer from 'puppeteer';
const browser = await puppeteer.launch({ headless: true });
try {
const page = await browser.newPage();
await page.setViewport({ width: 1440, height: 900, deviceScaleFactor: 1 });
await page.goto('https://example.com', { waitUntil: 'networkidle2', timeout: 60_000 });
// Full document screenshot:
await page.screenshot({ path: 'full-page.png', fullPage: true, type: 'png' });
// Or capture one element:
const element = await page.$('main');
if (!element) throw new Error('Could not find main element');
await element.screenshot({ path: 'main.png', type: 'png' });
} finally {
await browser.close();
}
This is a wrapper example, not a guarantee that networkidle2 means the page is visually complete. Use the readiness guidance above for pages with delayed or continuously changing content.
5. Raw CDP request and response flow
The protocol call itself is small. Your application must already have selected and attached to the right page target, and it must handle navigation, session lifecycle, and output-file writing. A representative command is:
{
"method": "Page.captureScreenshot",
"params": {
"format": "png",
"captureBeyondViewport": true
}
}
The response contains a base64 string in data. Decode it using your language’s base64 decoder and save the bytes, not the base64 text. The Chrome DevTools Protocol Page reference documents the method and its parameters. The protocol reference is a rolling tot document, so check the Chrome version you deploy when relying on browser-specific behavior.
6. Other client languages
CDP is a browser protocol, not a standalone command-line program. The following snippets show the essential request or decoding step; each still needs a CDP transport and an attached page session. Use the complete Puppeteer Node.js program above when you want a directly runnable CDP example without implementing target discovery and WebSocket session management yourself.
cURL: send a command over an existing CDP WebSocket
cURL can speak WebSocket in versions that support it, but a CDP connection also requires the browser’s WebSocket endpoint, a session/target, and the protocol’s request identifiers. Therefore, there is no portable one-line cURL command that captures an arbitrary URL by itself. Once your client has sent the command and received the response JSON, decode data locally; for example, if the response is saved to response.json:
jq -r '.result.data' response.json | base64 --decode > screenshot.png
The request body sent through your established CDP transport is:
{"id":1,"method":"Page.captureScreenshot","params":{"format":"png"}}
Python: decode a CDP response
After your Python CDP client receives the response object for the attached page session, save the returned data like this:
import base64
# response is the decoded JSON response from Page.captureScreenshot.
image_bytes = base64.b64decode(response["result"]["data"])
with open("screenshot.png", "wb") as image_file:
image_file.write(image_bytes)
The response variable must come from a CDP client that has connected to Chrome, attached to a page, navigated, and issued the method. The snippet alone does not perform those setup steps.
7. Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| Output file contains unreadable text or is corrupt | The base64 string was written directly, or decoded incorrectly. | Decode result.data as base64 and write binary bytes, as in Buffer.from(data, 'base64'). |
| “Target closed” or session errors | The browser/page closed before capture, or the CDP session is detached. | Keep the browser and page alive through capture; create the session from the intended page and detach only after writing the output. |
| Screenshot is blank or incomplete | Capture ran before client-rendered content or images were ready, or navigation did not reach the intended URL. | Check navigation errors and final URL; wait for a meaningful selector or application readiness condition; inspect the page before capture. |
| Cookie banner, popup, or chat widget appears | The page rendered the overlay and CDP captured it as part of the page. | Handle the overlay in your own workflow, such as by accepting consent or hiding a known selector before capture. CDP does not automatically remove these elements. |
| Full-page output is clipped or fails on a long site | The page exceeds a browser or environment limit, or the requested capture mode does not cover the desired area. | Try a wrapper’s full-page option, capture smaller clips, or reduce scale and dimensions. There is no universal maximum established by the cited protocol documentation. |
| JPEG output is unexpectedly large or poor quality | Quality was omitted or set without considering image content and file-size needs. | Set a JPEG quality appropriate to your use case, then compare output size and legibility. PNG may be preferable for crisp edges or transparency. |
| Output extension does not match the image | Requested format and filename extension differ. | Use .png, .jpg, or .webp to match the chosen format. |
| Navigation waits forever | The site maintains network activity or uses long-lived requests, making an idle condition unsuitable. | Use a selector or application-specific readiness signal instead of relying on network idle; set a timeout and report which stage failed. |
8. Performance, reliability, and cost
Performance
- Browser startup is separate work from the protocol capture. For repeated captures, a managed browser process can avoid launching Chrome for every URL, but isolate pages and sessions carefully.
- Large full-page images consume more memory and produce larger files. Prefer a viewport or clip when the task does not need the entire document.
- JPEG or WebP can reduce output size compared with PNG for many page designs, while format behavior and visual quality depend on the content and consumers.
- Waiting for a site-specific condition often avoids both premature captures and unnecessary fixed delays.
Reliability
- Pin and manage your browser and client versions in production; protocol behavior can vary with browser versions.
- Use timeouts for navigation and readiness waits, and handle failures at each stage separately.
- Set viewport dimensions, device scale factor, locale-sensitive settings, and capture options explicitly when output consistency matters.
- Do not assume an unusually tall capture will always work. Validate dimensions and image output for the actual target sites and Chrome versions.
Cost
Self-hosted CDP has no per-screenshot API charge from Chrome, but you pay for the machines, browser runtime, engineering work, storage, and operational maintenance you use. A hosted screenshot API trades some setup and operations for a service charge; compare the required features and billing rules before choosing.
Or skip the browser setup
ScreenshotNeo is a website screenshot API and MCP server. Its API returns a screenshot in one GET request. The call below saves the response as WebP:
curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o shot.webp
See the ScreenshotNeo API documentation for the request options. Python:
import requests
r = requests.get("https://api.screenshotneo.com/v1/shot", params={"access_key": "YOUR_API_KEY", "url": "https://stripe.com"}, timeout=90)
open("shot.webp", "wb").write(r.content)
Node.js:
const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://stripe.com' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
if (!res.ok) throw new Error(`Screenshot request failed: ${res.status}`);
await import('node:fs/promises').then(({ writeFile }) => writeFile('shot.webp', Buffer.from(await res.arrayBuffer())));
ScreenshotNeo accepts cookie and consent banners like a visitor and removes more than 60 known consent platforms, newsletter popups, and chat widgets before capture; each cleanup step can be turned off. Bot checks and CAPTCHAs, blank pages, timeouts, failed loads, and cache hits are not billed. Its MCP server gives AI agents tools for screenshots, page information, and PDF capture. The free plan includes 1,000 screenshots each month with no card; paid plans start at $5 for 3,000 screenshots. Every feature is on every plan.
Sign up for 1,000 free screenshots a month, with no card required.
FAQ
Does Page.captureScreenshot return an image file?
No. It returns base64-encoded image data. Your client decodes the response and writes the image bytes to a file.
Can CDP capture one element?
CDP’s screenshot method accepts a clip rectangle. For an element-based workflow, Puppeteer’s element screenshot helper locates the element and captures it, scrolling it into view when needed.
Does network idle guarantee a complete screenshot?
No. It is a navigation condition, not proof that every image, font, animation, or client-rendered component is visually ready.
Is there a documented maximum full-page height?
The cited protocol and Puppeteer documentation do not establish a universal maximum. Verify large captures with the browser version and pages you use.


