How to Reduce Memory Use When Screenshotting Many Pages with Puppeteer
Keep Puppeteer screenshot batches within a predictable memory budget with bounded concurrency, reliable page cleanup, and practical diagnostics.
To reduce memory use when screenshotting many pages with Puppeteer, limit how many pages are active at once, close each page in a finally block, write screenshots to files instead of retaining image buffers, and close the browser when the batch ends. Measure memory for both Node.js and Chrome in the environment where the job runs, then tune concurrency from those measurements; Puppeteer does not document one universally safe page count.
The pattern below processes a URL list with a bounded worker pool. Each worker creates a page, navigates, saves a screenshot, and closes the page whether the capture succeeds or fails.
1. Use a bounded worker pool
Install Puppeteer in a Node.js project with npm install puppeteer. Save the following as capture-batch.mjs and run it with node capture-batch.mjs. The URL list, output directory, and concurrency can be adjusted for your job. The worker pool and filename helper are application code, not Puppeteer APIs.
import puppeteer from 'puppeteer';
import { mkdir } from 'node:fs/promises';
import { createHash } from 'node:crypto';
import path from 'node:path';
const urls = [
'https://example.com',
'https://developer.mozilla.org/',
'https://pptr.dev/',
];
const outputDir = path.resolve('screenshots');
// Start small, then choose this value using measurements in your deployment.
const concurrency = 2;
function outputPathFor(url) {
// A URL-derived digest avoids collisions between URLs with similar titles.
const digest = createHash('sha256').update(url).digest('hex').slice(0, 16);
return path.join(outputDir, `${digest}.png`);
}
async function runWithConcurrency(items, limit, worker) {
if (!Number.isInteger(limit) || limit < 1) {
throw new RangeError('concurrency must be a positive integer');
}
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);
}
await mkdir(outputDir, { recursive: true });
const browser = await puppeteer.launch();
try {
await runWithConcurrency(urls, concurrency, async (url) => {
const page = await browser.newPage();
try {
page.setDefaultNavigationTimeout(45_000);
await page.goto(url, { waitUntil: 'networkidle2' });
await page.screenshot({ path: outputPathFor(url), fullPage: true });
console.log(`Saved ${url}`);
} catch (error) {
console.error(`Capture failed for ${url}:`, error);
// Continue the batch after a failed URL. Replace this with `throw error`
// if any failed capture should fail the entire job.
} finally {
await page.close();
}
});
} finally {
await browser.close();
}
Promise.all here waits for the fixed set of workers, not for one promise per URL. This prevents the script from opening a page for every URL at once. A failure is caught per URL so remaining URLs can proceed; change that policy if your batch must fail fast. The outer finally still closes the browser if setup or worker execution throws.
2. Tune concurrency from measurements
Concurrency is a throughput-versus-peak-resource tradeoff. A single active page is the simplest baseline and generally limits simultaneous page work. Increasing the worker count can improve throughput, but it also allows more page rendering, image decoding, JavaScript execution, and screenshot work to overlap. Page cost depends on site content, viewport, full-page height, and browser environment, so a value suitable for one batch may be too high for another.
- Run a representative batch with concurrency set to
1. - Record Node.js resident memory, Chrome child-process memory, active page count, completed URL count, failures, and elapsed time.
- Repeat with a modestly higher concurrency using representative heavy pages as well as ordinary pages.
- Keep the highest concurrency that fits the deployment’s memory and reliability limits, with room for variation in page content.
Do not infer a safe limit from a few lightweight pages or from a Puppeteer-wide rule: the reviewed documentation specifies no universal page count or memory target. In containers, also consider the memory limit and whether other work shares the same process or host.
3. Release pages and browser resources reliably
Close every page after its capture
Use await page.close() in finally. Navigation may time out, a site may fail to load, or screenshot writing may throw. Without deterministic cleanup, those paths can leave pages open while later work continues. Puppeteer’s Page API says page closing waits for screenshots in progress in the browser context, which helps avoid racing a close against a capture.
Close the browser when the batch is complete
await browser.close() gracefully closes the browser and its pages. Put it in an outer finally, as in the example. If you connected to a browser managed elsewhere, browser.disconnect() only disconnects Puppeteer; it does not close the browser or its pages. Do not use disconnect as a cleanup substitute when your goal is to stop the browser.
Use contexts when isolation or grouped cleanup helps
A browser context groups pages and provides an isolation boundary: cookies and local storage are not shared across separate contexts. Closing a context closes its pages. This can be useful when a group of captures intentionally shares session state and should be cleaned up together. Contexts are not a reason to leave individual work unbounded; still cap the pages you create concurrently.
const context = await browser.createBrowserContext();
try {
const page = await context.newPage();
try {
await page.goto('https://example.com', { waitUntil: 'networkidle2' });
await page.screenshot({ path: 'screenshots/example.png' });
} finally {
await page.close();
}
} finally {
// Also closes any pages in this context if an earlier path left one open.
await context.close();
}
4. Avoid retaining screenshot data in Node.js
When you pass { path: 'file.png' } to page.screenshot(), the capture is written to that path. This keeps your application code from holding the returned screenshot bytes for later processing. If you need the bytes for an upload or transformation, release references as soon as the operation finishes and avoid accumulating buffers for the whole batch. The Puppeteer guide demonstrates saving to a path; it does not quantify a memory reduction from doing so.
For in-memory processing, process each result before capturing the next URL, and ensure queues or retry records do not retain large buffers. If output files are needed, create unique names: using a page title alone can overwrite captures or collide for repeated titles.
5. Choose navigation and screenshot work deliberately
The example uses waitUntil: 'networkidle2', matching Puppeteer’s screenshot guide. Some applications keep network connections open or load content after the network becomes quiet. In those cases, use a readiness condition appropriate to the site, such as waiting for a known selector, or a different navigation milestone. Avoid adding long fixed delays to every URL unless the page actually requires them: they increase batch duration and leave worker slots occupied.
Full-page captures can require more rendering and image work than a viewport capture, especially for long documents. If the output only needs a particular component, capture that element instead:
const element = await page.waitForSelector('main article');
if (!element) throw new Error('Article element was not found');
await element.screenshot({ path: 'screenshots/article.png' });
Puppeteer documents ElementHandle.screenshot() for a specific element; it scrolls the element into view if needed. Prefer a viewport or element capture when it meets the requirement, rather than capturing an entire long page without a need.
6. Diagnose memory growth and leftover processes
Separate application memory from browser memory. Node’s process memory alone does not represent Chrome’s total resident memory. Track the Node process and Chrome child processes separately, and correlate them with active pages, completed captures, and failures. Compare a one-page baseline with the intended concurrency using pages that reflect production content: large images, long pages, animations, and heavy scripts can change resource use.
For retained JavaScript heap objects inside a page, Puppeteer provides page.captureHeapSnapshot(). This captures a JavaScript heap snapshot; it is not a measurement of all Chrome native or process memory. Take snapshots selectively because they are diagnostic artifacts and can be large.
import { mkdir } from 'node:fs/promises';
await mkdir('diagnostics', { recursive: true });
const page = await browser.newPage();
try {
await page.goto('https://example.com');
await page.captureHeapSnapshot({
path: 'diagnostics/page.heapsnapshot',
});
} finally {
await page.close();
}
Check the installed Puppeteer version’s API reference for the exact heap snapshot options before using this diagnostic in production, since the API may evolve.
7. Handle failures and partial batches
- Navigation timeout: the site may be slow, never reach the chosen lifecycle condition, or keep network activity open. Set a realistic navigation timeout and use a site-specific readiness condition where needed. Keep the page-close
finallyin place. - Screenshot error: verify the output directory exists and is writable, check that the page has not already closed, and capture the failing URL and error. Do not retry indefinitely; limit retries and let each attempt release its page.
- Duplicate filenames: derive names from a stable URL identifier or include an index, and decide whether reruns should overwrite or preserve prior output.
- Memory rises with every URL: check that every page closes after success and failure, buffers are not retained in arrays or queues, concurrency stays bounded, and the browser is closed after the batch.
- Chrome remains after the job: inspect the child processes and container process supervision. Puppeteer’s troubleshooting guide discusses zombie Chrome processes in some PID 1/container setups and suggests considering
dumb-init(or Docker’s init support) in that situation. This is process reaping guidance, not a general memory optimization. - Concurrent operations appear serialized: Puppeteer documents that creating pages and closing pages in a browser context can wait for screenshots in progress. Account for this coordination when interpreting throughput; do not assume every create, screenshot, and close operation runs independently.
8. Or skip the browser setup
ScreenshotNeo is a website screenshot API and MCP server. A single GET request captures a URL as an image or PDF, so your application does not need to launch and manage a local Puppeteer browser for this capture path. See 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()
with open("shot.webp", "wb") as f:
f.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(`Screenshot request failed: ${res.status}`);
await import('node:fs/promises').then(({ writeFile }) =>
writeFile('shot.webp', Buffer.from(await res.arrayBuffer()))
);
Cookie and consent banners, newsletter popups, and chat widgets are removed before the shot; each cleanup step can be disabled. Bot checks, blank pages, timeouts, failed loads, and cache hits are not billed, and response headers identify the page verdict and billing status. An 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.
Create a free ScreenshotNeo account to get 1,000 screenshots a month with no card.
FAQ
Should I close a Puppeteer page after every screenshot?
For a batch that creates a page per URL, yes: close it after the capture in a finally block so errors do not skip cleanup.
Does browser.disconnect() free Chrome’s memory?
It disconnects Puppeteer from the browser but leaves the browser and pages open. Use browser.close() when Puppeteer owns the browser and the batch is finished.
What is the right number of concurrent pages?
There is no universal documented number. Measure the pages and resource limits in your deployment, starting with one active page.
Does a page heap snapshot show total Chrome memory?
No. It captures the page’s JavaScript heap, not all browser process and native allocations.
Sources
- Puppeteer Screenshots guide
- Puppeteer Browser management guide (Next documentation; confirm details against the installed version)
- Puppeteer Page API
- Puppeteer Troubleshooting


