Hero Background

Next-Gen App & Browser Testing Cloud

Trusted by 2 Mn+ QAs & Devs to accelerate their release cycles

Next-Gen App & Browser Testing Cloud

How to handle performance issues, such as slow script execution or high memory usage in Puppeteer?

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.

Why Puppeteer Scripts Get Slow or Memory-Hungry

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.

Causes and Fixes at a Glance

SymptomLikely causeFix
Memory keeps climbingPages or browsers never closedClose in try/finally; reuse one browser
Orphaned chrome processesCrash before browser.close()Guard cleanup; close contexts
RAM spikes under loadUnbounded concurrencyCap with a pool or queue
Slow page loadsLoading images, fonts, adsBlock via request interception
Navigation hangsnetworkidle0 on chatty pagesUse domcontentloaded
Flaky, slow stepsArbitrary sleepsUse explicit waits
Crashes in Docker64MB /dev/shm--disable-dev-shm-usage

Fix High Memory 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 time

Dispose 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.

Fix Slow Script Execution

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.

Monitor Memory and Performance

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.

Run Puppeteer at Scale on a Cloud Grid

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();

Frequently Asked Questions

Why does my Puppeteer script keep using more and more memory?

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.

How do I make Puppeteer pages load faster?

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.

Should I launch a new browser for every task?

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.

What launch arguments help Puppeteer in Docker?

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.

How do I measure memory usage inside a Puppeteer script?

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.

How do I run many Puppeteer scripts in parallel without exhausting memory?

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.

Related Questions

Test Your Website on 3000+ Browsers

Get 100 minutes of automation test minutes FREE!!

Test Now...

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

  • Advanced access controls
  • Advanced data retention rules
  • Advanced Local Testing
  • Premium Support options
  • Early access to beta features
  • Private Slack Channel
  • Unlimited Manual Accessibility DevTools Tests