Take Screenshots of Multiple URLs in Parallel with Node.js and Puppeteer
Capture one screenshot per URL with Puppeteer, using separate pages and bounded concurrency. Includes runnable Node.js code, troubleshooting, and a no-browser-setup option.
Use one Puppeteer browser, create a separate Page for each active URL, and limit how many pages run at once. Each page navigates independently and saves its own screenshot. A bounded worker pool prevents a large URL list from opening every page simultaneously; Puppeteer documents the browser and page APIs, but does not publish a universal concurrency limit.
Puppeteer’s basic capture call is Page.screenshot(). Its screenshots guide shows launching a browser, opening a page, navigating, saving a screenshot, and closing the browser. The examples below use Puppeteer 25.12.0 documentation as the reference point; the system requirements page listed Node.js 22.12 or later when this guide was prepared. Check the current system requirements and your installed package version before deployment.
1. Install Puppeteer
mkdir parallel-shots
cd parallel-shots
npm init -y
npm install puppeteer
Puppeteer downloads a compatible browser during installation by default. If your environment supplies a browser separately, use the Puppeteer configuration appropriate to that environment and provide its executable path when launching. The examples assume the standard installation.
2. Capture URLs with bounded concurrency
Save this as capture.mjs and run it with node capture.mjs. It uses one browser and one page per active job, while a fixed number of workers pull URLs from the list. Each URL gets an index-based filename, so similar URL paths cannot overwrite each other. Failed jobs are reported individually, and the browser closes even if a capture fails.
import puppeteer from 'puppeteer';
import { mkdir } from 'node:fs/promises';
import path from 'node:path';
const urls = [
'https://example.com/',
'https://developer.mozilla.org/',
'https://nodejs.org/',
];
const outputDir = path.resolve('screenshots');
const concurrency = 3; // Tune for your machine and target sites.
const viewport = { width: 1440, height: 900, deviceScaleFactor: 1 };
await mkdir(outputDir, { recursive: true });
const browser = await puppeteer.launch({ headless: true });
let nextIndex = 0;
const failures = [];
async function worker() {
while (true) {
const index = nextIndex++;
if (index >= urls.length) return;
const url = urls[index];
const filename = `${String(index + 1).padStart(4, '0')}.png`;
const outputPath = path.join(outputDir, filename);
let page;
try {
page = await browser.newPage();
await page.setViewport(viewport);
await page.goto(url, {
waitUntil: 'networkidle2',
timeout: 45_000,
});
await page.screenshot({ path: outputPath, fullPage: true });
console.log(`Saved ${url} → ${outputPath}`);
} catch (error) {
failures.push({ url, message: error instanceof Error ? error.message : String(error) });
console.error(`Failed ${url}: ${error instanceof Error ? error.message : error}`);
} finally {
if (page) await page.close().catch(() => {});
}
}
}
try {
const workerCount = Math.min(Math.max(1, concurrency), urls.length);
await Promise.all(Array.from({ length: workerCount }, () => worker()));
} finally {
await browser.close();
}
if (failures.length) {
console.error(`\n${failures.length} of ${urls.length} captures failed.`);
process.exitCode = 1;
}
The worker count is an operational setting, not a Puppeteer limit or official recommendation. Start conservatively, then adjust after observing memory use, navigation times, and failures on your own machine and target sites. An empty URL list creates the output directory and launches then closes the browser without creating workers.
3. Understand the concurrency pattern
- One browser, multiple pages: a Puppeteer
Browsercan hold multiplePageinstances. Give each independent URL its own page so navigations and captures do not compete for one page’s state. See the Page class reference. - Bounded workers: the pool above never has more than
concurrencyjobs active. It works for long lists without constructing a promise and page for every URL at once. - Per-URL failures: a failed navigation or screenshot is recorded and the other queued jobs continue. The script exits with a nonzero status after the batch if any URL failed.
- Unique outputs: index-based names are collision-safe within this run. If you need names that remain stable across reordered input lists, derive them from a URL hash and retain the original URL in a manifest.
For a small, known list, Promise.all(urls.map(...)) is concise, but it starts every mapped task together. Do not use unbounded fan-out for an arbitrarily large list. The official documentation establishes the APIs, not a safe page count or throughput figure.
4. Choose navigation and screenshot options
Navigation readiness
The example uses waitUntil: 'networkidle2' as a practical choice for pages that settle after their requests quiet down. It can wait too long on pages with persistent network activity. If that happens, use waitUntil: 'domcontentloaded' or 'load', then wait for a page-specific selector or a deliberate delay. A successful navigation event does not guarantee that every dynamic widget, chart, or image has finished rendering.
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30_000 });
await page.waitForSelector('main article', { timeout: 10_000 });
await page.screenshot({ path: outputPath, fullPage: true });
Use selectors that actually identify the content on the target pages. If sites differ, make the wait strategy configurable per URL rather than assuming one selector works everywhere.
Viewport, full-page, and image format
page.setViewport({ width, height, deviceScaleFactor })controls the viewport dimensions and pixel scale. Set it before navigation when responsive layout matters.fullPage: truecaptures the full page. Omit it for a viewport screenshot.clip: { x, y, width, height }captures a specific rectangle instead of the full page.typeselects an image format;pathsaves the file, and the extension determines the type when no type is specified.qualityapplies to lossy formats such as JPEG, not PNG. Use a value supported by your installed Puppeteer version.omitBackground: truerequests a transparent background when the chosen format supports it.
See the current ScreenshotOptions reference for the complete option list and version-specific behavior. Keep filenames and extensions consistent with the selected format.
await page.screenshot({
path: outputPath,
type: 'jpeg',
quality: 82,
fullPage: false,
});
For PNG, remove quality. For transparency, use a format that supports it and set omitBackground: true. Large full-page screenshots can have substantial dimensions; consider a viewport capture or clip when you only need a section.
5. Run captures through a command-line URL list
For repeatable jobs, read URLs from a newline-delimited file. Replace the hard-coded list initialization in the example with this small loader; blank lines and comment lines are ignored.
import { readFile } from 'node:fs/promises';
const urls = (await readFile('urls.txt', 'utf8'))
.split(/\r?\n/)
.map(line => line.trim())
.filter(line => line && !line.startsWith('#'));
Example urls.txt:
https://example.com/
https://developer.mozilla.org/
https://nodejs.org/
6. Performance, reliability, and cost
- Measure concurrency locally: each active page consumes browser resources, and target sites vary. Increase the worker count gradually while watching memory, CPU, completion time, and timeout rates. There is no documented universal optimum.
- Reuse one browser for a batch: the example avoids launching a separate browser process for every URL. If a long-running batch shows browser instability, split it into smaller batches and restart between them.
- Bound time and retries: navigation has a timeout. For transient failures, add a small, capped retry policy around an individual URL; do not retry permanent errors indefinitely. Record attempts and final outcomes so a partial batch can be resumed.
- Control output size: full-page captures, high device scale factors, and PNG output can increase disk use and processing time. Choose viewport, scale, and format based on the image’s use.
- Protect destinations and credentials: only capture URLs you are authorized to access. If you add cookies or authorization headers, keep secrets out of source control and logs.
- Cost: Puppeteer is a local browser automation library; the code itself has no per-screenshot API charge. Budget for the machine, storage, and operational work needed to run it. Hosted infrastructure, if used, has its own pricing and should be checked separately.
7. Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| Browser executable or launch failure | The browser was not installed, is incompatible, or the runtime cannot launch it. | Install Puppeteer with its browser download enabled, check the current system requirements, and inspect launch errors. If using a separately installed browser, configure its executable path explicitly. |
TimeoutError during navigation |
The site is slow, unreachable, or never becomes idle because it keeps network connections open. | Check the URL and network access; raise the timeout only when justified, or use a less strict readiness event followed by a selector wait. |
| Screenshot is blank or missing content | The capture ran before client-rendered content appeared, or the content is below the viewport and full-page capture was not enabled. | Wait for a meaningful selector or known render signal. Set fullPage: true when the whole document is required. |
| Wrong responsive layout | The page was captured at an unintended viewport or device scale. | Set the viewport before navigation and confirm the desired width, height, and scale. |
| Files overwrite each other | Concurrent jobs share a fixed output path. | Use a unique filename per input, such as the index-based names in the example; ensure output directories are shared intentionally across runs. |
| Machine runs out of memory or slows down | Too many pages are active, or captures are very large. | Lower concurrency, reduce scale or capture area, and process the input in smaller batches. |
| One bad URL prevents useful results | Errors are allowed to escape the per-URL task. | Catch errors inside each worker, retain the URL and error, close its page in finally, and summarize failures after the batch. |
| Images differ between runs | Dynamic content, time-dependent data, animation, or network timing changed. | Use a consistent viewport and readiness condition; where appropriate, disable animation or hide volatile elements with page styling. |
Or skip the browser setup
ScreenshotNeo is a website screenshot API and MCP server. A single request returns an image or PDF, so you do not need to install or manage a browser for this capture.
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 request options. Cookie and consent banners are accepted and 60+ known consent platforms, newsletter popups, and chat widgets are removed before capture; each step can be turned off. Bot checks, blank pages, and failed loads are never billed, and response headers report the page verdict and billing status. Its MCP server lets AI agents use screenshot tools. The free plan includes 1,000 screenshots a 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.
FAQ
Can I use one Puppeteer page for all URLs?
You can navigate one page repeatedly for serial work, but it cannot independently capture multiple URLs at the same time. Use a separate page per active URL for parallel jobs.
Does Puppeteer specify a maximum number of parallel pages?
The cited documentation does not give a universal page or job limit. Choose a bounded concurrency value for your environment and measure it.
Does this capture screenshots on my computer?
Yes. The example launches a local Puppeteer browser and writes images into the screenshots directory.
Can I return image bytes instead of writing files?
Yes. Page.screenshot() resolves with image data. Omit path and pass or store the returned data as needed; consult the method reference for the installed version’s return type and details.


