Next-Gen App & Browser Testing Cloud
Trusted by 2 Mn+ QAs & Devs to accelerate their release cycles

Handle Puppeteer performance issues by always closing what you open, reusing a single browser, and loading only what you need. In practice that means calling await page.close() and await browser.close() in a try/finally so handles never leak, reusing one browser with lightweight pages or contexts instead of relaunching Chromium per task, blocking unneeded images, CSS, fonts, and analytics with request interception, capping concurrency, and waiting on explicit conditions rather than arbitrary sleeps. When you outgrow a single machine, connect to a remote browser grid so the heavy Chromium processes run off your local box.
Puppeteer drives a real Chromium instance. Every browser you launch is a full Chromium process, and every page and BrowserContext holds its own renderer, memory, and event listeners. Performance problems almost always trace back to one of two patterns: you create more of these objects than you close (memory climbs, orphaned processes accumulate), or you make Chromium do work it doesn't need to do, such as downloading images you never read or waiting for a network that never goes quiet (scripts run slow).
The fixes fall into the same two buckets. The table below maps common causes to their fixes, and the sections after it show the code.
| Symptom | Likely cause | Fix |
|---|---|---|
| Memory keeps climbing | Pages or browsers never closed | Close in try/finally; reuse one browser |
| Orphaned chrome processes | Crash before browser.close() | Guard cleanup; close contexts |
| RAM spikes under load | Unbounded concurrency | Cap with a pool or queue |
| Slow page loads | Loading images, fonts, ads | Block via request interception |
| Navigation hangs | networkidle0 on chatty pages | Use domcontentloaded |
| Flaky, slow steps | Arbitrary sleeps | Use explicit waits |
| Crashes in Docker | 64MB /dev/shm | --disable-dev-shm-usage |
Always close pages and browsers in a try/finally. A crash between launch and cleanup leaks the whole Chromium process. Wrapping the work in try/finally guarantees the page and browser close even when an error is thrown.
const puppeteer = require('puppeteer');
const browser = await puppeteer.launch({ headless: true });
const page = await browser.newPage();
try {
await page.goto('https://example.com', { waitUntil: 'domcontentloaded' });
// ... your work ...
} finally {
await page.close(); // free the page's renderer
await browser.close(); // kill the Chromium process
}Reuse one browser, isolate with contexts. Launching a fresh browser per URL is the most expensive thing you can do, because each launch spawns a new Chromium process. Launch once, then open a BrowserContext per task to get a clean cookie and storage jar without a new process. Closing the context frees everything in it at once.
const browser = await puppeteer.launch({ headless: true });
async function scrape(url) {
const context = await browser.createBrowserContext(); // isolated, no new Chromium process
const page = await context.newPage();
try {
await page.goto(url, { waitUntil: 'domcontentloaded' });
return await page.title();
} finally {
await context.close(); // closes the page and clears its storage
}
}
// createBrowserContext() replaces the older createIncognitoBrowserContext()Cap concurrency. Opening dozens of pages at once spikes RAM and can crash the process. Run tasks in small batches sized to your available memory and CPU instead of firing them all with Promise.all.
async function runInBatches(urls, worker, concurrency = 4) {
const results = [];
for (let i = 0; i < urls.length; i += concurrency) {
const batch = urls.slice(i, i + concurrency);
results.push(...await Promise.all(batch.map(worker)));
}
return results;
}
await runInBatches(urls, scrape, 4); // at most 4 pages open at a timeDispose handles and remove listeners. Holding on to an ElementHandle or JSHandle keeps the underlying DOM object alive, and re-adding page.on(...) listeners inside a loop leaks them. Call handle.dispose() when you are done with a handle, and pair every page.on with a page.off if you attach inside repeated work.
Set the right Docker args. Chromium uses /dev/shm, which defaults to 64MB in containers and causes Target closed crashes under load. Add --disable-dev-shm-usage, and in trusted CI containers add --no-sandbox.
Block resources you don't need. If your script only reads the DOM, downloading images, stylesheets, fonts, media, and third-party analytics is wasted time and bandwidth. Turn on request interception and abort those resource types.
await page.setRequestInterception(true);
page.on('request', (request) => {
const blocked = ['image', 'stylesheet', 'font', 'media'];
if (blocked.includes(request.resourceType())) {
request.abort();
} else {
request.continue();
}
});Pick the cheapest wait condition. The default load and the stricter networkidle0 wait for events that may never settle on pages with long-polling or analytics beacons. If you only need the parsed DOM, use domcontentloaded, then wait for the specific element you care about.
// Slow: waits for the network to be idle for 500ms
await page.goto(url, { waitUntil: 'networkidle0' });
// Faster: DOM parsed, then wait only for what you need
await page.goto(url, { waitUntil: 'domcontentloaded' });
await page.waitForSelector('#results', { timeout: 10000 });Replace sleeps with explicit waits. A hard-coded waitForTimeout(5000) is both slow and flaky, it either over-waits or fires too early. Wait on the actual condition with waitForSelector, waitForFunction, or waitForResponse.
// Avoid: pauses for a fixed 5 seconds regardless of state
await page.waitForTimeout(5000);
// Prefer: continue the instant the condition is true
await page.waitForFunction(() => document.querySelectorAll('.item').length > 0);Run headless and trim startup. Headless mode (the default in current Puppeteer) skips the cost of rendering a visible window. Keep the cache enabled with page.setCacheEnabled(true) so repeat visits reuse assets, and pass a few lightweight flags to reduce overhead.
const browser = await puppeteer.launch({
headless: true, // current Chrome "new" headless is the default
args: [
'--disable-dev-shm-usage', // avoid 64MB /dev/shm crashes in Docker
'--no-sandbox', // CI/containers only, trusted content
'--disable-gpu',
'--disable-extensions',
],
});The --single-process flag can shave memory but makes Chromium fragile and crash-prone, so use it only with caution and never as a default.
Before you tune blindly, measure. page.metrics() returns the JavaScript heap size, node count, and document count, log it before and after a run to confirm whether memory actually grows. For a deeper bottleneck analysis, record a Chrome trace and open it in DevTools.
const before = await page.metrics();
await page.goto('https://example.com', { waitUntil: 'domcontentloaded' });
const after = await page.metrics();
console.log('Heap used (MB):', (after.JSHeapUsedSize / 1048576).toFixed(1));
console.log('DOM nodes:', after.Nodes, 'documents:', after.Documents);
// Record a trace for DevTools analysis
await page.tracing.start({ path: 'trace.json', screenshots: true });
// ... interactions ...
await page.tracing.stop();On the host, also watch for leftover chrome and chrome_crashpad processes after your script exits. Their presence is a reliable sign that a browser was never closed.
Local tuning only goes so far. A single machine has a hard ceiling on how many Chromium instances it can host before RAM and CPU run out, so heavy parallel workloads will hit memory pressure no matter how carefully you close handles. The way past that ceiling is to move the browsers off your machine.
Instead of launching Chromium locally, connect to a remote browser with puppeteer.connect() so the heavy processes run on the grid. Running Puppeteer on a cloud platform such as Puppeteer lets you fan out across many parallel sessions and browser and OS combinations without consuming local memory.
// Run the browser remotely instead of on your machine
const browser = await puppeteer.connect({
browserWSEndpoint: 'wss://your-remote-grid-endpoint',
});
const page = await browser.newPage();
await page.goto('https://example.com', { waitUntil: 'domcontentloaded' });
await browser.close();The most common cause is leaked pages and browsers. Every page and browser holds a Chromium process or handle, so forgetting await page.close() and await browser.close(), especially on the error path, lets memory grow and orphaned chrome processes accumulate. Close handles in a try/finally, reuse a single browser, and cap how many pages you open at once.
Block resources you don't need with page.setRequestInterception(true), aborting image, stylesheet, font, and media requests. Use waitUntil: 'domcontentloaded' instead of networkidle0 when you only need the DOM, replace arbitrary sleeps with explicit waits like waitForSelector, and run in headless mode.
No. Launching a fresh browser per URL is slow and memory-heavy because each launch spins up a new Chromium process. Launch one browser, then open and close a page or a BrowserContext per task. Use browser.createBrowserContext() when you need an isolated cookie and storage jar without a new Chromium process.
Add --disable-dev-shm-usage so Chromium does not crash against the default 64MB /dev/shm in containers. In trusted CI containers you typically also need --no-sandbox, while --disable-gpu and --disable-extensions reduce overhead. Use --single-process only with caution, as it can destabilize Chromium.
Call await page.metrics(), which returns JSHeapUsedSize, JSHeapTotalSize, Nodes, Documents, and LayoutCount. Log it before and after a run to spot growth. For deeper analysis, record a Chrome trace with page.tracing.start() and page.tracing.stop() and open it in DevTools.
A single machine can host only a limited number of Chromium instances before RAM and CPU run out. Cap local concurrency with a small pool or queue, and for larger scale connect to a remote browser grid using puppeteer.connect({ browserWSEndpoint }) so the heavy Chromium processes run off your local machine.
KaneAI - Testing Assistant
World’s first AI-Native E2E testing agent.

TestMu AI forEnterprise
Get access to solutions built on Enterprise
grade security, privacy, & compliance