ScreenshotNeo

BlogHow-to

How to Take Website Screenshots of Multiple URLs with Puppeteer

Capture a list of URLs with Puppeteer using sequential or bounded parallel workers, with runnable code, reliable output paths, and error handling.

By the ScreenshotNeo team4 October 202611 min read

To take website screenshots of multiple URLs with Puppeteer, launch one browser, create a page for each URL, navigate to it, wait for the visual state you need, save the screenshot to a unique path, and close the page. Process URLs sequentially for a small list. For a larger list, use a bounded worker pool so you control how many pages are open at once.

The examples below use Node.js ES modules and Puppeteer. Puppeteer documents Page.screenshot() as its screenshot method, and a single browser can create multiple pages. See the Puppeteer screenshot guide and BrowserContext.newPage() reference.

1. Install Puppeteer and prepare the URL list

Create a project and install Puppeteer. The puppeteer package downloads a compatible Chrome for Testing browser during installation; if your environment manages Chrome separately, consult the official installation guide.

mkdir puppeteer-batch-shots
cd puppeteer-batch-shots
npm init -y
npm install puppeteer

Set the project to use ES modules by adding "type": "module" to package.json, or use the CommonJS form noted below. Save the following as capture.mjs. It creates the output directory, captures each URL in order, logs failures per URL, and closes each page and the browser even when something goes wrong.

2. Capture a small list sequentially

import puppeteer from 'puppeteer';
import { mkdir } from 'node:fs/promises';

const urls = [
  'https://example.com',
  'https://example.org',
];

const outputDir = 'screenshots';
await mkdir(outputDir, { recursive: true });

const browser = await puppeteer.launch({ headless: true });
const results = [];

try {
  for (const [index, url] of urls.entries()) {
    const outputPath = `${outputDir}/page-${String(index + 1).padStart(3, '0')}.png`;
    let page;

    try {
      page = await browser.newPage();
      // Set the viewport before navigation so the page lays out at these dimensions.
      await page.setViewport({ width: 1365, height: 900 });

      const response = await page.goto(url, {
        waitUntil: 'load',
        timeout: 30_000,
      });

      // Navigation can resolve for HTTP error responses. Record them explicitly.
      const status = response?.status() ?? null;
      if (status !== null && status >= 400) {
        throw new Error(`HTTP ${status}`);
      }

      await page.screenshot({
        path: outputPath,
        type: 'png',
        fullPage: true,
      });

      results.push({ url, status, outputPath, ok: true });
      console.log(`Saved ${url} -> ${outputPath}`);
    } catch (error) {
      results.push({ url, outputPath, ok: false, error: error.message });
      console.error(`Failed ${url}: ${error.message}`);
    } finally {
      if (page) await page.close();
    }
  }
} finally {
  await browser.close();
}

const failures = results.filter((result) => !result.ok);
console.log(`Finished: ${results.length - failures.length}/${results.length} succeeded.`);
if (failures.length) process.exitCode = 1;

Run it with node capture.mjs. The output uses stable numeric names rather than putting arbitrary URL text into filesystem paths. Keep the results records or write them to JSON if a later process needs a URL-to-file mapping.

Why one page per URL?

Each page has its own navigation and viewport. Closing it after the screenshot releases page-level resources before the next URL starts. You can reuse a single page and navigate it repeatedly to reduce page creation, but a fresh page makes per-URL cleanup and isolation easier to reason about. In either case, launch the browser once for the batch rather than once per URL.

3. Capture many URLs with a bounded worker pool

When there are many URLs, workers can process independent pages at the same time. That may overlap navigation waits, but it also uses more CPU and memory and can put more simultaneous traffic on target sites. Puppeteer documents multiple pages per browser, but does not specify a universally safe worker count or guarantee a speedup. Start with a small limit, observe your machine and destination sites, and adjust for your workload.

This version gives each worker a fresh page, assigns each URL index once, records individual failures, and waits for every worker before closing the browser.

import puppeteer from 'puppeteer';
import { mkdir } from 'node:fs/promises';

const urls = [
  'https://example.com',
  'https://example.org',
  'https://www.iana.org/domains/reserved',
];

const outputDir = 'screenshots';
const workerCount = 3; // Tune for your machine and sites; this is an example, not a universal limit.
const results = new Array(urls.length);
let nextIndex = 0;

await mkdir(outputDir, { recursive: true });
const browser = await puppeteer.launch({ headless: true });

async function worker() {
  while (true) {
    const index = nextIndex++;
    if (index >= urls.length) return;

    const url = urls[index];
    const outputPath = `${outputDir}/page-${String(index + 1).padStart(3, '0')}.png`;
    let page;

    try {
      page = await browser.newPage();
      await page.setViewport({ width: 1365, height: 900 });
      const response = await page.goto(url, {
        waitUntil: 'load',
        timeout: 30_000,
      });
      const status = response?.status() ?? null;
      if (status !== null && status >= 400) throw new Error(`HTTP ${status}`);

      await page.screenshot({ path: outputPath, type: 'png', fullPage: true });
      results[index] = { url, status, outputPath, ok: true };
      console.log(`Saved ${url} -> ${outputPath}`);
    } catch (error) {
      results[index] = { url, outputPath, ok: false, error: error.message };
      console.error(`Failed ${url}: ${error.message}`);
    } finally {
      if (page) await page.close();
    }
  }
}

try {
  const count = Math.min(workerCount, urls.length);
  await Promise.all(Array.from({ length: count }, () => worker()));
} finally {
  await browser.close();
}

const failures = results.filter((result) => result && !result.ok);
console.log(`Finished: ${results.length - failures.length}/${results.length} succeeded.`);
if (failures.length) process.exitCode = 1;

Choose the sequential version when the list is short, simplicity matters, or you want minimal simultaneous load. Choose bounded workers when the list is large enough that doing all navigation one by one is inconvenient and your environment can support concurrent pages. Avoid unbounded Promise.all(urls.map(...)) for a large list: it opens work for every URL at once, which can exhaust memory, browser resources, file descriptors, or destination capacity.

4. Choose the page readiness condition

page.goto() accepts a URL with a scheme such as https:// and resolves with the main resource response (or null in cases such as about:blank). Its default wait condition is load, and the documented default navigation timeout is 30 seconds. A valid HTTP response such as 404 or 500 may still resolve; inspect response.status() if that matters. See the Page.goto() reference and WaitForOptions reference.

Setting Use it when Tradeoff
waitUntil: 'load' The page’s load event is a reasonable point to capture. Some JavaScript applications render important content after load.
waitUntil: 'domcontentloaded' The document structure is enough, or speed matters more than late resources. Images and other resources may still be loading.
waitUntil: 'networkidle0' The page is expected to become quiet with no network connections for the lifecycle interval. Analytics, polling, or long-lived requests may prevent the condition.
waitUntil: 'networkidle2' A small amount of ongoing network activity is acceptable. Network quiet does not guarantee the exact application state you need.
Wait for a selector A known element signals that the content you need is present. Requires a stable selector and a timeout strategy.

For a site that renders a specific report after navigation, wait for its content selector after goto:

await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30_000 });
await page.waitForSelector('[data-report-ready="true"]', {
  visible: true,
  timeout: 15_000,
});
await page.screenshot({ path: outputPath, fullPage: true });

Replace the example selector with an element the application actually renders. A fixed delay with await new Promise(resolve => setTimeout(resolve, 2000)) can help with a known animation, but it is less reliable than waiting for a meaningful page condition: slow runs may need longer and fast runs waste time. Treat timeouts as per-URL failures so one slow site does not stop the batch.

5. Configure screenshots and output names

Puppeteer’s screenshot options include path, fullPage, clip, type, and quality. Quality applies to JPEG or WebP, not PNG. See the full ScreenshotOptions reference.

Need Example option Notes
Visible viewport only fullPage: false This is the default. Set viewport dimensions before navigation when layout size matters.
Whole document fullPage: true Tall pages produce larger images and may take longer to capture.
JPEG output type: 'jpeg', quality: 80 Choose a quality appropriate for the visual details and file size you need.
WebP output type: 'webp', quality: 80 Quality applies; confirm downstream tools accept WebP.
PNG output type: 'png' Lossless raster output; the quality option does not apply.
Region only clip: { x, y, width, height } Coordinates describe the capture region; use dimensions that fit the page.

To capture a specific element, wait for it and call its screenshot method:

const element = await page.waitForSelector('.product-card', { visible: true });
if (!element) throw new Error('Product card was not found');
await element.screenshot({ path: outputPath });

Use output names that remain unique across repeated or parallel work. A stable list index is simple; for recurring runs, combine a sanitized hostname or your own record ID with a timestamp or run ID. Do not use an unchecked URL as a path: query strings and special characters can create invalid names or unintended directory paths. If you rerun the same batch into the same directory, decide whether replacing earlier files is intended.

6. Browser state, cookies, and isolation

Pages created in the same browser context share context storage such as cookies and local storage. That is useful when all captures should use one session. If each URL should start with isolated storage, create a separate browser context per job or per isolation group, then create pages inside it. Puppeteer documents that separate browser contexts isolate cookies and local storage; see Browser.createBrowserContext() and BrowserContext.

const context = await browser.createBrowserContext();
try {
  const page = await context.newPage();
  await page.setViewport({ width: 1365, height: 900 });
  await page.goto(url, { waitUntil: 'load', timeout: 30_000 });
  await page.screenshot({ path: outputPath, fullPage: true });
} finally {
  await context.close(); // closes pages in this context too
}

Use isolated contexts when pages must not inherit a prior site’s session. Use a shared context only when session sharing is deliberate. For logged-in pages, handle credentials and cookies securely, and do not put secrets into URLs or logs.

7. Common errors and fixes

Error or symptom Likely cause Fix
Navigation timeout of 30000 ms exceeded The site is slow, waits on ongoing requests, or the chosen lifecycle event never occurs. Use a condition matching the desired state, increase the timeout for that URL, or wait for a page-specific selector. Keep a finite timeout so the batch can continue.
Screenshot saved, but the page is blank or incomplete The application renders after navigation or the selected wait condition happens too early. Wait for a visible content selector or a known application-ready condition before capture.
HTTP 404 or 500 screenshot goto() can resolve for valid HTTP error statuses. Inspect response.status() and choose whether to save, flag, or skip error pages.
net::ERR_NAME_NOT_RESOLVED or connection refused DNS, network access, proxy, or destination availability problem. Check the URL and network path from the machine running Chrome. Record the error and retry separately if appropriate.
ENOENT while saving The output directory does not exist or the path is invalid. Create it with mkdir(outputDir, { recursive: true }) and use a safe filename.
Files overwrite one another Workers use the same fixed output name. Derive each path from a unique index or job identifier.
Browser crashes or the machine slows down Too many pages or large full-page images are active concurrently. Lower the worker limit, capture viewport-only images when sufficient, and close every page in finally.
Images or fonts are missing Capture happens before resources finish, or the site loads them lazily. Wait for required selectors/resources or use a later readiness condition. Check the site’s network and rendering behavior.
Navigation to a URL fails immediately The URL lacks a scheme, contains a typo, or is unsupported from the runtime environment. Pass an absolute URL such as https://example.com and verify it is reachable from the host.

8. Performance, reliability, and cost

  • Reuse the browser: starting one browser per URL adds repeated startup overhead. Keep one browser alive for the batch and close it once all work settles.
  • Bound concurrency: more simultaneous pages consume more resources and may increase load on destination sites. There is no universal safe count; tune against the machine and workload rather than relying on an invented benchmark.
  • Keep failures local: catch errors around each URL, store status and error details, and let other jobs proceed. Set a nonzero process exit code if any capture failed so automation can detect partial failure.
  • Use finite timeouts: a timeout bounds how long a problematic destination can occupy a worker. Choose values based on the expected site and capture requirement.
  • Mind full-page size: very tall documents can require more memory and produce large files. Capture only the viewport or a relevant element if that meets the task.
  • Expect local resource costs: Puppeteer runs Chrome on the host, so the work consumes that host’s CPU, memory, disk, and network. The dossier establishes no universal throughput figure or per-capture cost.
  • Respect destination capacity: avoid sending a large burst to the same site. Consider rate limits and access policies before increasing parallel work.

9. Or skip the browser setup

If you need a hosted screenshot endpoint instead of maintaining a Puppeteer runtime, ScreenshotNeo takes one GET request per URL and returns an image or PDF. Its API accepts familiar screenshot parameter names, which can make switching from another screenshot API easier. Read the ScreenshotNeo API documentation.

curl -G "https://api.screenshotneo.com/v1/shot" \
  -d access_key=YOUR_API_KEY \
  --data-urlencode url=https://example.com \
  -o shot.webp
import requests

r = requests.get(
    "https://api.screenshotneo.com/v1/shot",
    params={"access_key": "YOUR_API_KEY", "url": "https://example.com"},
    timeout=90,
)
r.raise_for_status()
open("shot.webp", "wb").write(r.content)
const q = new URLSearchParams({
  access_key: 'YOUR_API_KEY',
  url: 'https://example.com',
});
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
if (!res.ok) throw new Error(`ScreenshotNeo returned HTTP ${res.status}`);
const bytes = new Uint8Array(await res.arrayBuffer());
await import('node:fs/promises').then(({ writeFile }) => writeFile('shot.webp', bytes));

ScreenshotNeo accepts cookie or consent banners as 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, blank pages, timeouts, failed loads, and cache hits are not billed, and response headers report the page verdict and billing status. An MCP server provides take_screenshot, get_page_info, and capture_pdf tools 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 captures. Sign up for free: 1,000 screenshots a month, no card required.

10. FAQ

Can Puppeteer take screenshots of all URLs at exactly the same time?

It can keep several pages open and process them concurrently, but they will not capture at an exact synchronized instant. Use bounded workers for overlapping work and coordinate a separate readiness condition if timing matters.

Should I use one browser or launch one browser per URL?

For a normal batch, one browser with a page per active task is simpler and avoids repeatedly starting Chrome. Separate browser contexts when storage isolation is needed.

Will Puppeteer automatically save a screenshot when a page returns 404?

It may navigate successfully because an HTTP error status is still a valid response. Check the response status and explicitly choose whether to keep that screenshot.

Can I capture only part of a website?

Yes. Use a screenshot clip for a coordinate region or wait for an element and call its screenshot() method.

Does Puppeteer require a fixed delay before every screenshot?

No. Prefer waiting for the page condition or selector that indicates the content you need. Use a delay only for a known timing behavior such as a transient animation.