How to Capture a List of URLs as PNG Screenshots Using Chrome DevTools Protocol
Use Chrome DevTools Protocol to capture one PNG per URL, handle navigation and readiness, and retry failures. Includes runnable Node.js and ScreenshotNeo examples.
To capture a list of URLs as PNG files with Chrome DevTools Protocol (CDP), navigate a Chrome page to each URL, wait for the page state your task requires, call Page.captureScreenshot with format: "png", decode its base64 response, and write the bytes to a file. The example below uses Node.js and Puppeteer to launch Chrome while exposing the CDP commands directly. It saves one file per URL and records failures per URL so one bad page does not stop the batch.
CDP is useful when you need protocol-level control over navigation and screenshot parameters. If you only need a convenient browser automation API, Puppeteer’s page.screenshot() is a higher-level alternative. For either method, a screenshot reflects the page state at capture time; your code must decide what “ready” means for the sites being captured.
1. Set up the batch capture
Use Node.js 18 or later and install Puppeteer, which downloads a compatible browser by default. Save the following as capture-list.mjs.
npm init -y
npm install puppeteer
Put one URL per line in urls.txt. For example:
https://example.com/
https://developer.mozilla.org/
https://www.chromium.org/
This script opens a fresh page, attaches a CDP session, enables the Page domain, navigates, waits for the document load event and then for a short configurable settling period, and captures a viewport PNG. It writes a JSONL result record for every URL. Change the readiness wait to fit the pages you own; a fixed delay cannot prove that an application has finished rendering.
import fs from 'node:fs/promises';
import path from 'node:path';
import puppeteer from 'puppeteer';
const inputFile = process.argv[2] ?? 'urls.txt';
const outputDir = process.argv[3] ?? 'screenshots';
const settleMs = Number(process.env.SETTLE_MS ?? 1000);
const navigationTimeoutMs = Number(process.env.NAV_TIMEOUT_MS ?? 30000);
const urls = (await fs.readFile(inputFile, 'utf8'))
.split(/\r?\n/)
.map(line => line.trim())
.filter(line => line && !line.startsWith('#'));
await fs.mkdir(outputDir, { recursive: true });
const resultsFile = path.join(outputDir, 'results.jsonl');
await fs.writeFile(resultsFile, '');
const browser = await puppeteer.launch({ headless: true });
function safeName(value, index) {
const host = new URL(value).hostname.replace(/[^a-z0-9.-]/gi, '_');
return `${String(index + 1).padStart(3, '0')}-${host}.png`;
}
try {
for (let index = 0; index < urls.length; index++) {
const url = urls[index];
const filename = safeName(url, index);
const filePath = path.join(outputDir, filename);
let page;
let record;
try {
page = await browser.newPage();
page.setDefaultNavigationTimeout(navigationTimeoutMs);
const cdp = await page.createCDPSession();
await cdp.send('Page.enable');
const navigation = await cdp.send('Page.navigate', { url });
if (navigation.errorText) {
throw new Error(`Navigation failed: ${navigation.errorText}`);
}
// Page.navigate returns before all site-specific rendering is necessarily done.
// Puppeteer's load wait helps with ordinary documents; apps may need a selector wait.
await page.waitForNavigation({ waitUntil: 'load', timeout: navigationTimeoutMs })
.catch(error => {
// The navigation can complete before this waiter is registered or be a
// same-document navigation. Do not silently treat every timeout as ready.
if (error.name !== 'TimeoutError') throw error;
});
if (settleMs > 0) await new Promise(resolve => setTimeout(resolve, settleMs));
const shot = await cdp.send('Page.captureScreenshot', {
format: 'png',
fromSurface: true,
captureBeyondViewport: false
});
await fs.writeFile(filePath, Buffer.from(shot.data, 'base64'));
record = { url, file: filename, status: 'ok' };
} catch (error) {
record = { url, file: filename, status: 'error', error: String(error.message ?? error) };
} finally {
if (page) await page.close().catch(() => {});
}
await fs.appendFile(resultsFile, `${JSON.stringify(record)}\n`);
console.log(JSON.stringify(record));
}
} finally {
await browser.close();
}
Run it with:
node capture-list.mjs urls.txt screenshots
Each successful capture is stored under screenshots/. Failed URLs appear in the console and screenshots/results.jsonl; correct or retry those rows without repeating successful captures.
Make readiness specific to the site
The example waits for the browser’s load event, then a settling delay. That is a starting point, not a universal readiness guarantee. For a known app, wait for a meaningful selector after navigation:
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: navigationTimeoutMs });
await page.waitForSelector('[data-page-ready="true"]', { timeout: 15000 });
Use a selector that appears only after the content relevant to the screenshot is present. If a page has no reliable readiness marker, use a bounded delay and inspect representative output. Avoid waiting for network idle as a blanket rule on pages with analytics, streaming requests, or long polling; those requests may never become idle.
2. Understand the CDP calls and screenshot options
The protocol work is small: enable the Page domain, navigate, then capture. The official [Chrome DevTools Protocol Page reference](https://chromedevtools.github.io/devtools-protocol/tot/Page/) documents the method parameters and response fields.
Page.enableenables Page-domain events and functionality for the session.Page.navigatestarts navigation. It can return aframeId, an optionalloaderId, and anerrorTextwhen navigation cannot proceed. Same-document navigation may not have a loader ID.Page.captureScreenshotreturns image data as a base64 string. Decode that string before writing a binary file.
| Option | Use | Notes |
|---|---|---|
format |
png, jpeg, or webp |
PNG is the documented default. Set it explicitly when the output must be PNG. |
quality |
Compression quality for supported lossy formats | It does not make PNG lossy; omit it for PNG captures. |
clip |
Capture a rectangle | Supply the clip rectangle and scale when only a region is needed. Coordinate and page-scale choices affect the result. |
captureBeyondViewport |
Capture beyond the visible viewport | The protocol reference marks this option experimental. Verify support in the Chrome version you run before depending on it. |
fromSurface |
Capture from the compositor surface | Useful for ordinary rendered page screenshots; test unusual browser and embedded-content cases. |
captureScreenshot response data |
Image bytes encoded as base64 | Decode it with Buffer.from(data, 'base64') in Node.js. |
For a viewport image, set the viewport before navigation so responsive layouts render at the intended dimensions:
await page.setViewport({ width: 1440, height: 900, deviceScaleFactor: 1 });
A viewport screenshot only includes the visible area. Full-page behavior depends on the capture approach and browser support. Puppeteer’s screenshot API provides a higher-level full-page option; direct CDP users can use protocol parameters such as captureBeyondViewport where supported, but should confirm behavior against their target Chrome version. For a stable full-page workflow, prefer and verify the wrapper’s full-page implementation or capture deliberate viewport tiles and stitch them.
3. Choose direct CDP or Puppeteer’s screenshot API
Direct CDP is appropriate when your workflow needs protocol method parameters, response fields, or events. Puppeteer is convenient when navigation, browser lifecycle, and writing screenshots matter more than direct protocol control. Both operate on Chrome page targets, and Puppeteer supports multiple page instances in a browser.
The script above uses Puppeteer to launch and manage Chrome but calls Page.navigate and Page.captureScreenshot through a CDP session. To use the wrapper for the image call instead, replace the CDP capture portion with:
await page.screenshot({ path: filePath, type: 'png' });
See the [Puppeteer screenshot guide](https://pptr.dev/guides/screenshots) for its documented screenshot workflow. Choose one approach based on the control and integration your team needs; do not assume experimental protocol options behave identically across Chrome versions.
4. Make batch runs reliable
- Validate inputs. Reject malformed URLs before starting Chrome, and decide whether to allow only
http:andhttps:. - Keep per-URL results. Store the original URL, output path, status, and error together. The JSONL log in the example makes partial runs recoverable.
- Set finite timeouts. Bound navigation and application-ready waits so one stalled page does not block the entire list.
- Retry selectively. Retry transient navigation or browser failures with a small bounded retry count. Do not retry permanent errors such as invalid URLs indefinitely.
- Use unique output names. Host-only filenames can collide when the input contains the same host more than once. Include a stable input index or ID, as the example does.
- Control concurrency. The example is sequential, which limits simultaneous browser work and simplifies diagnosis. If you parallelize, use a bounded worker pool, give each task its own page, and monitor memory and browser stability on your workload.
- Close pages and Chrome. Use cleanup paths even after a failed navigation to avoid leaking renderer processes or browser resources.
- Record the capture context. For reproducibility, log viewport, device scale factor, timestamp, browser version, readiness rule, and relevant authentication or locale setup alongside each result.
CDP describes individual navigation and capture operations; it does not prescribe a universal concurrency limit, timeout, or performance target. Measure those against your own pages and environment.
5. Troubleshoot common failures
| Symptom | Likely cause | Fix |
|---|---|---|
| No screenshot file or a zero-byte file | The response data was not decoded or file writing failed. | Check the capture response, decode its base64 data field, and preserve filesystem errors in the per-URL result. |
| Screenshot is blank or shows a loading state | The capture ran before application content rendered, or navigation failed. | Check Page.navigate.errorText, wait for a page-specific ready selector, and inspect the browser console or page state. |
| Navigation wait times out | The page is slow, never reaches the chosen event, or keeps connections open. | Set a finite timeout and wait for a more appropriate lifecycle event or application selector. Do not treat every timeout as proof of readiness. |
| Same-page links or hash changes behave differently | Same-document navigation may omit loaderId and not behave like a new document load. |
Handle the returned fields as optional and wait for the state change your capture requires. |
| PNG contains only the visible part of a long page | A viewport capture was requested. | Choose a supported full-page strategy, verify it with the Chrome version in use, or capture and assemble intentional page regions. |
captureBeyondViewport is rejected or has no effect |
The target Chrome protocol version may not support an experimental parameter or may implement it differently. | Check the protocol reference for the browser version and use a supported wrapper or viewport-based approach. |
| Output files overwrite one another | Names were based on a non-unique URL component such as hostname. | Add a stable index or unique identifier to every output name. |
| Browser exits during a large run | Renderer crashes, resource pressure, or unhandled failures can interrupt a long process. | Persist results after each URL, restart the browser between bounded batches if needed, and resume only failed or unfinished rows. |
| Images or fonts are missing | They had not loaded when capture began, or the page blocked or deferred them. | Wait for relevant images or application state; test lazy-loaded content and the intended scroll/full-page strategy. |
6. Performance, reliability, and cost
Launching one browser and reusing it across a sequential list avoids repeated startup work, while a new page per URL keeps page state isolated. Larger viewports and full-page captures produce more pixels and can take more memory and time. Images, fonts, scripts, and application data also affect each page’s load and render time. There is no universal CDP batch speed or safe parallelism number in the protocol documentation; benchmark a representative subset on the machine and pages you will actually use.
For reliable output, treat navigation success and visual readiness as separate checks. A navigation can succeed while the application is still fetching data, and a page can render a usable state despite a late subresource failure. Save results incrementally, retry only failures that may be transient, and retain enough context to reproduce a capture.
With self-hosted Chrome, direct protocol capture has no per-screenshot API charge, but you pay for the compute, storage, maintenance, and engineering time of operating the browser workflow. A hosted screenshot API trades browser operations for a per-plan request allowance; compare its billing rules, outputs, and required controls with your workload.
7. Or skip the browser setup
ScreenshotNeo is a website screenshot API and MCP server. One GET request returns an image or PDF, so you can capture URLs without provisioning and managing a Chrome process. The examples below use PNG output and the API documented at ScreenshotNeo’s API docs.
cURL
curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o shot.png
Python
import requests
r = requests.get(
"https://api.screenshotneo.com/v1/shot",
params={"access_key": "YOUR_API_KEY", "url": "https://stripe.com", "format": "png"},
timeout=90,
)
r.raise_for_status()
with open("shot.png", "wb") as f:
f.write(r.content)
Node.js
import { writeFile } from 'node:fs/promises';
const q = new URLSearchParams({
access_key: 'YOUR_API_KEY',
url: 'https://stripe.com',
format: 'png'
});
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
if (!res.ok) throw new Error(`Screenshot request failed: ${res.status} ${await res.text()}`);
await writeFile('shot.png', Buffer.from(await res.arrayBuffer()));
To process a URL list, repeat the request for each URL and use a unique output name, just as with the local CDP workflow. ScreenshotNeo removes cookie and consent banners, newsletter popups, and chat widgets before capture; each cleanup step can be turned off. Bot checks, blank pages, timeouts, failed loads, and cache hits are not billed, and response headers report the page verdict and billing status. Its MCP server provides take_screenshot, get_page_info, and capture_pdf for Claude, Cursor, and other MCP clients. The free plan includes 1,000 screenshots per month with no card; paid plans start at $5 for 3,000 screenshots.
Sign up for 1,000 free screenshots a month, with no card required.
8. Frequently asked questions
Does CDP return a PNG file?
No. Page.captureScreenshot returns base64-encoded image data. Decode it and write the resulting bytes to a file with a .png extension.
Can I capture pages that require login?
Yes, if the Chrome page has the required authenticated session, cookies, or other access state before navigation. The capture command does not configure that access for you.
Does a successful navigation mean the screenshot is complete?
No. It means navigation was initiated or completed according to the browser response, not that every application request or visual update has finished. Use a readiness condition that matches the page.
Can I use the same CDP workflow for JPEG or WebP?
Yes. The protocol supports PNG, JPEG, and WebP. Set the desired format explicitly and use a matching filename extension; PNG is the documented default.


