Puppeteer memory leaks: find them, fix them, stop the 3 a.m. restarts

Updated July 2026 · applies to Puppeteer 22+ on Docker/Kubernetes/bare Node

The pattern is always the same: the service deploys at 300 MB, creeps for hours, and dies at the container limit. Four causes account for nearly every Puppeteer memory leak in production — in this order.

1. Leaked pages (the big one)

Every newPage() is a Chromium renderer process. If any code path exits without page.close() — a timeout, bad HTML, a thrown exception — that renderer lives on. One leaked page per hundred requests is a dead container by morning.

// LEAK: the page (an entire renderer process) survives every thrown error
app.post('/pdf', async (req, res) => {
  const page = await browser.newPage();
  await page.setContent(req.body.html);        // throws? page leaks.
  const pdf = await page.pdf({ format: 'A4' }); // throws? page leaks.
  await page.close();                           // never reached on error
  res.type('application/pdf').send(pdf);
});

2. Browser bloat: recycle the whole process

Even with perfect page hygiene, a Chromium process grows with use — caches, heap fragmentation, compositor state. Long-running services recycle the browser on two triggers: job count and resident memory. Here is a complete wrapper that fixes #1 and #2:

// renderer.mjs — production-shaped Puppeteer wrapper:
// per-job pages in try/finally, browser recycling by job count AND by RSS.
import puppeteer from 'puppeteer';

const MAX_JOBS_PER_BROWSER = 100;
const MAX_RSS_BYTES = 1.5 * 1024 ** 3; // recycle beyond 1.5GB

let browser = null;
let jobsSinceLaunch = 0;

async function getBrowser() {
  if (browser?.connected && jobsSinceLaunch < MAX_JOBS_PER_BROWSER
      && process.memoryUsage().rss < MAX_RSS_BYTES) {
    return browser;
  }
  if (browser) await browser.close().catch(() => {}); // reaps child processes
  browser = await puppeteer.launch({
    args: ['--disable-dev-shm-usage', '--no-zygote'],
    protocolTimeout: 30_000, // a hung CDP call must fail, not hold memory forever
  });
  jobsSinceLaunch = 0;
  return browser;
}

export async function renderPdf(html) {
  const b = await getBrowser();
  jobsSinceLaunch++;
  // Context = cheap per-job isolation (cookies, storage, cache).
  // Older Puppeteer: createIncognitoBrowserContext().
  const context = await b.createBrowserContext();
  const page = await context.newPage();
  try {
    page.setDefaultTimeout(20_000);
    await page.setContent(html, { waitUntil: 'networkidle0' });
    return await page.pdf({ format: 'A4', printBackground: true });
  } finally {
    await page.close().catch(() => {});
    await context.close().catch(() => {});
  }
}

3. Zombie Chromium processes

ps aux | grep defunct showing a graveyard of chrome entries? Chromium’s child processes need an init to reap them when the parent crashes or a launch is interrupted:

# Zombie chrome processes: Chromium's children must be reaped by an init.
docker run --init my-pdf-service            # easiest

# or in the Dockerfile:
# RUN apt-get install -y tini
# ENTRYPOINT ["tini", "--", "node", "server.mjs"]

# /dev/shm is 64MB by default in Docker — either raise it:
docker run --shm-size=1g my-pdf-service
# ...or keep --disable-dev-shm-usage in your launch args (as above).

4. Watch it so it can’t surprise you

// Watchdog: log RSS + browser PID memory each minute; restart on runaway.
// (npm install pidusage)
import pidusage from 'pidusage';

setInterval(async () => {
  const rss = process.memoryUsage().rss;
  const chrome = browser?.process()
    ? await pidusage(browser.process().pid).catch(() => null)
    : null;
  console.log(JSON.stringify({
    node_rss_mb: Math.round(rss / 1e6),
    chrome_mb: chrome ? Math.round(chrome.memory / 1e6) : null,
  }));
}, 60_000);
// Pair with a container memory limit + liveness restart as the last backstop.

Related: running on Lambda instead of a long-lived service? The failure mode is different — see fixing Puppeteer OOM on Lambda.

FAQ

Why does Puppeteer memory grow even though I close every page?

A long-lived Chromium process accumulates memory anyway: caches, fragmented heaps, compositor state and session history all grow with use. Closing pages is necessary but not sufficient — production services also recycle the whole browser process every N renders (or when RSS crosses a threshold).

What are the defunct/zombie chrome processes in my container?

Chromium spawns child processes (renderers, GPU). When the parent dies without reaping them — a crash, a killed launch, a skipped browser.close() — they become zombies that hold process-table entries and shared memory. Run your container with an init process (docker run --init, or tini as PID 1) so orphans get reaped automatically.

Does --disable-dev-shm-usage help with leaks?

It fixes a related crash rather than a leak: Docker gives /dev/shm only 64MB by default, and Chromium fills it, then crashes mid-render — which in turn leaves orphaned processes behind (see zombies above). Either pass --disable-dev-shm-usage or raise --shm-size to 1GB.

Should I use one browser or many?

One browser with one page per job is right for most services. Isolate jobs with browser contexts (cheap, per-job cookies/storage) rather than extra browsers. Scale by running more service replicas, not more browsers per process — it keeps memory accounting and recycling simple.

…or skip all of this with one API call. NippyPDF is a managed Chromium rendering core behind a single POST /v1/pdf — no browser fleet, no page-break roulette, around $0.003/document.

Try the live demoJoin the waitlistWhat it costs to keep self-hosting →