ScreenshotNeo

BlogHow-to

How to Take Parallel Website Screenshots with Puppeteer Without Crashing Chrome

Use a bounded Puppeteer worker pool to capture sites in parallel, isolate browser state when needed, and clean up pages reliably without assuming a universal safe concurrency limit.

By the ScreenshotNeo team4 October 202610 min read

Use a bounded worker pool: run only a measured number of screenshot jobs at once, give each job its own Puppeteer Page, and close each page in a finally block. Use separate BrowserContext instances when jobs must not share cookies or local storage. There is no documented universal safe number of concurrent pages; tune the limit on the host and workload you will actually run.

Launching one Chrome process per URL or opening every URL at once makes resource use harder to control. A single browser can hold multiple pages, but that capability is not a promise that unlimited navigation and capture will fit available memory or CPU. Puppeteer documents Page.screenshot() for image capture and notes that screenshot operations can make certain page operations wait in the same context. Puppeteer screenshot guide · BrowserContext API · Page.screenshot API

1. Install Puppeteer and choose a concurrency limit

This example uses Puppeteer’s bundled browser, a fixed-size worker pool, a navigation timeout, and cleanup for both pages and the browser. It writes one PNG per input URL into an output directory. The concurrency value is a starting setting for measurement, not a recommended universal maximum.

npm install puppeteer

Save this as parallel-shots.js:

const fs = require('node:fs/promises');
const path = require('node:path');
const puppeteer = require('puppeteer');

const urls = process.argv.slice(2);
if (urls.length === 0) {
  console.error('Usage: node parallel-shots.js URL [URL ...]');
  process.exit(2);
}

// Measure this value on the machine and pages you will use.
const concurrency = Number(process.env.CONCURRENCY || 2);
if (!Number.isInteger(concurrency) || concurrency < 1) {
  throw new Error('CONCURRENCY must be a positive integer');
}
const outputDir = path.resolve('screenshots');

function safeName(url, index) {
  let host = 'page';
  try { host = new URL(url).hostname.replace(/[^a-z0-9.-]/gi, '_'); } catch {}
  return `${String(index + 1).padStart(4, '0')}-${host}.png`;
}

async function runPool(items, limit, worker) {
  let next = 0;
  const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {
    while (true) {
      const index = next++;
      if (index >= items.length) return;
      await worker(items[index], index);
    }
  });
  await Promise.all(workers);
}

(async () => {
  await fs.mkdir(outputDir, { recursive: true });
  const browser = await puppeteer.launch({ headless: true });
  try {
    await runPool(urls, concurrency, async (url, index) => {
      let page;
      try {
        page = await browser.newPage();
        page.setDefaultNavigationTimeout(45_000);
        const response = await page.goto(url, { waitUntil: 'domcontentloaded' });
        if (!response) {
          console.warn(`No main-resource response: ${url}`);
        } else if (!response.ok()) {
          console.warn(`HTTP ${response.status()} for ${url}`);
        }
        await page.screenshot({
          path: path.join(outputDir, safeName(url, index)),
          fullPage: true,
          type: 'png'
        });
        console.log(`Saved ${url}`);
      } catch (error) {
        console.error(`Failed ${url}: ${error.message}`);
      } finally {
        if (page && !page.isClosed()) await page.close().catch(() => {});
      }
    });
  } finally {
    await browser.close();
  }
})().catch(error => {
  console.error(error);
  process.exitCode = 1;
});

Run it with a small initial limit, then adjust after observing the run:

CONCURRENCY=2 node parallel-shots.js https://example.com https://stripe.com https://pptr.dev

The sample deliberately catches per-URL errors so one failed site does not discard other results. A worker is reused for the next URL after its current capture finishes. The sequence counter assigns each input to one worker at a time in this single-process script.

2. What the pool controls—and what it does not

The pool bounds the number of active jobs. Each active job has a separate page, and each page is closed even when navigation or capture throws. The browser is closed when all workers finish, including when pool setup or execution fails.

Concern Choice in the example When to change it
Concurrency CONCURRENCY environment variable, default 2 Raise gradually only if resource use, completion time, and failures remain acceptable on the target host.
Navigation readiness domcontentloaded Use a different readiness condition if the page needs additional client rendering; any wait can affect latency and timeout frequency.
Navigation timeout 45 seconds Adjust for known slow targets. A longer timeout also means a worker can remain occupied longer.
Image output Full-page PNG Use viewport capture or another format when that suits the required output and resource budget.
State isolation Pages from the browser’s default context Create a context per job or isolation group when cookies and local storage must be separate.

Puppeteer’s official material describes multiple pages per browser and the relevant lifecycle APIs, but does not prescribe a safe page count or memory-per-page figure. A light text page and a script-heavy page with large images can have very different costs. Establish a limit from representative pages, not from a generic number.

3. Isolate cookies and local storage with BrowserContexts

Pages in the same browser context share that context’s browser state. If jobs must not affect one another—for example, captures for different accounts—create a non-default context for each job and close it after capture. Closing a context closes its pages. The default context cannot be closed, so only close contexts your code created.

const context = await browser.createBrowserContext();
try {
  const page = await context.newPage();
  await page.goto(url, { waitUntil: 'domcontentloaded' });
  await page.screenshot({ path: outputPath });
} finally {
  await context.close();
}

Context-per-job gives a clear isolation and cleanup boundary but adds context creation and teardown to every task. If a group of captures is meant to share a login session, use one context for that group and manage its pages carefully instead. Do not assume separate pages alone isolate cookies or local storage.

4. Choose navigation and screenshot behavior deliberately

Wait condition

The example uses domcontentloaded, which waits for the document’s DOM to be parsed without requiring all subresources or ongoing network activity to finish. Use load when the page’s load event is the relevant milestone. Use networkidle0 or networkidle2 only when network quiet is a useful signal for the target; analytics, polling, and long-lived requests can make network-idle waits unsuitable. For a site with a known rendered element, navigate and then wait for that selector:

await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 45_000 });
await page.waitForSelector('.report-ready', { timeout: 15_000 });
await page.screenshot({ path: outputPath, fullPage: true });

When waiting for a selector, use a selector that indicates the content is ready rather than merely present in the initial markup. Handle selector timeout as a job failure or a deliberate fallback, depending on the application.

Full-page versus viewport

fullPage: true captures the full document height; it can produce very tall images and more work than a viewport screenshot. Omit it when only the visible viewport is needed. Pages that lazy-load content may need scrolling or another page-specific readiness procedure before a full-page capture; do not assume navigation alone loaded every off-screen asset.

Screenshot serialization

Puppeteer documents that, while a screenshot is in progress in a BrowserContext, newPage() and Page.close() wait for the screenshot to finish. bringToFront() does not wait for it. This helps explain some timing behavior, but it does not protect Chrome from excessive simultaneous navigation, memory pressure, or CPU saturation. Keep each job’s page ownership simple and avoid bringing pages to the foreground as a concurrency workaround.

5. Measure and tune the pool on the real host

  1. Start with a low concurrency value and a representative mix of target sites.
  2. Record the number of completed captures, duration, navigation and selector timeouts, page or browser errors, and browser exits.
  3. Observe host memory and CPU while the pool runs. Also note whether output dimensions or full-page captures correlate with pressure.
  4. Increase concurrency in small steps. Repeat with the same workload and browser setup.
  5. Back off when failure rates, timeouts, browser exits, or resource pressure increase. Keep the lower stable value as the operational limit.
  6. Run a sustained batch, not just a single URL, before adopting a setting for production.

Compare one browser with multiple pages against multiple browser processes by measured throughput, stability, isolation needs, cleanup scope, launch overhead, and recovery behavior. The official docs do not benchmark these deployment shapes, so any conclusion depends on your workload and host. If you connect to a managed or separately supervised browser, define who owns its lifecycle and recovery.

6. Reliability, cleanup, and version choices

  • Always clean up in finally. Close a job’s page, or close its dedicated context. Close a locally launched browser after the pool completes.
  • Know the difference between close and disconnect. browser.close() closes a locally managed browser. browser.disconnect() detaches Puppeteer from a running browser while leaving that browser and its pages open. Use disconnect only when another process owns browser lifetime.
  • Record versions for incidents. Capture the Puppeteer version and browser version alongside error details and concurrency settings.
  • Start with Puppeteer’s bundled browser. Puppeteer says it is only guaranteed to work with its bundled browser. Using another executable is at your own risk; reproduce instability with the bundled browser before changing executable paths.
  • Keep retries bounded. If your application retries transient navigation failures, cap attempts and backoff, and retain the worker limit across retries. Immediate unlimited retries can keep an overloaded browser busy.
  • Make outputs unique. Include an input index or stable job ID in file names so parallel tasks do not overwrite one another.

See the official browser management guide and LaunchOptions reference for launch and connection lifecycle details.

7. Troubleshooting common failures

Symptom Likely cause Practical fix
Chrome exits or the process is killed Too many active jobs for the host, or unusually heavy pages and full-page captures. Reduce the pool limit; test representative pages; observe memory and CPU; increase only in measured steps.
Navigation timeout The site is slow, keeps requests open, or the selected wait condition is too strict. Choose the readiness event that matches the task, set a suitable timeout, or wait for a specific ready selector. Keep a timeout so one job cannot occupy a worker forever.
Screenshot is blank or incomplete Capture happened before the page’s content was ready, or the page failed to render required client content. Check navigation response and page errors; wait for the content-specific selector or a deliberate delay; confirm the expected viewport and output path.
Jobs see another job’s login or preferences Pages share a BrowserContext and therefore browser storage. Use a dedicated context per job or per isolation group, and close the created context after use.
Page creation or closing appears to pause during capture Puppeteer documents that newPage() and Page.close() wait for screenshot completion in the same context. Allow the operation to complete; avoid treating this wait as a deadlock without checking whether a screenshot is active.
Browser remains running after script exits The script disconnected rather than closing a locally owned browser, or cleanup did not run. Use a finally block and browser.close() for a locally launched browser. Use disconnect only when another owner manages it.
Instability starts after changing Chrome executable The selected executable may not match the Puppeteer version. Record versions and reproduce with Puppeteer’s bundled browser first; consult the LaunchOptions guidance before using another executable.
Some output files replace others Parallel jobs generated the same path. Include a stable unique job identifier or input index in each filename.

8. Performance and cost considerations

Concurrency can improve batch completion time when jobs have independent waiting time, but each active page also consumes host resources. More workers can increase contention and failures instead of improving useful throughput. Measure completed screenshots per unit time together with timeouts, browser exits, CPU, and memory; throughput alone can hide a brittle setting.

Keep full-page capture, image size, waits, and retries proportional to the result you need. Long waits and slow targets hold worker slots; indiscriminate retries add load. There is no source-backed universal resource estimate or safe concurrency number, so calculate capacity on your own workload. For a local Puppeteer workflow, direct software cost depends on where Chrome runs; include the operational cost of maintaining that host and supervising failures in your own estimate.

9. Or skip the browser setup

ScreenshotNeo is a website screenshot API and MCP server from Yorker Media. Instead of managing Chrome workers, send one GET request for a URL. See the API documentation for options and response details.

curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o shot.webp
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)
const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://stripe.com' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
  • Cookie and consent banners, newsletter popups, and chat widgets are removed before the shot; each cleanup step can be turned off.
  • Bot checks, blank pages, timeouts, failed loads, and cache hits cost nothing. Response headers report the page verdict and billing status.
  • An MCP server provides take_screenshot, get_page_info, and capture_pdf for AI agents and MCP clients.
  • The free plan includes 1,000 screenshots a 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.

10. FAQ

How many Puppeteer pages can run at once?

There is no universal documented safe count. Measure with your actual pages, browser version, and host, then set a limit that stays stable under a sustained batch.

Does a separate Page isolate cookies?

No. Use separate BrowserContexts when jobs need isolated cookies or local storage.

Should I launch a browser for every URL?

Usually begin by measuring a bounded pool of pages in one browser. Compare multiple processes only when your workload or failure isolation needs justify that added lifecycle and supervision work.

Should I use browser.close() or browser.disconnect()?

Close a browser your script launched and owns. Disconnect when the browser is managed elsewhere and must remain running.

Why does Puppeteer work with its downloaded Chrome but not my system Chrome?

Puppeteer guarantees compatibility with its bundled browser, while another executable may not match the installed Puppeteer version. Verify against the bundled browser and record both versions when diagnosing the issue.