Puppeteer memory leaks: find them, fix them, stop the 3 a.m. restarts
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 demo Join the waitlist What it costs to keep self-hosting →