Debugging Headless Chrome Memory Leaks

Puppeteer works fine in development. In production, under concurrent load, memory climbs through the day, Chrome processes accumulate, and eventually your container OOMs and the process restarts. This guide covers the four most common causes of headless Chrome memory leaks, how to detect each one, and three fixes that actually work.
The last section is honest: if your leak is a genuine Chromium bug or your team is spending hours per sprint on browser reliability, the right answer might be stopping. That section covers the decision.
Prerequisites
- Node.js 20+ with Puppeteer installed (
npm install puppeteer) - A long-running server process handling screenshot or rendering requests
- Access to container metrics or
process.memoryUsage()output
Why Headless Chrome Leaks Memory
Chrome's browser process is stateful. It maintains renderer processes, JavaScript heaps, image caches, service workers, and IndexedDB state across page loads. In a long-running server, each page or browser context that is not properly closed adds permanently to this state until the process restarts.
A headless Chrome memory leak in production almost always traces to one of these four root causes:
- Unclosed pages:
browser.newPage()allocates a new renderer process. If your request handler throws before callingpage.close(), that renderer stays alive indefinitely. - Unhandled rejections bypassing cleanup: A
try/catchblock around the core capture logic that does not reach afinallyclause leaves pages open on every error path. - Event listener accumulation: Calling
page.on('request', handler)inside a per-request function without removing the listener afterward creates unbounded listeners on the page object, which holds references and prevents GC. - Browser process never recycled: Reusing one browser instance for all requests saves startup time but lets Chrome's internal caches grow without bound over hundreds or thousands of requests.
Step 1: Detect and Measure the Leak
Before writing a single fix, confirm you have an actual headless Chrome memory leak and not a leak in your application code or Node.js runtime. Add RSS tracking around your capture handler:
function logMemory(label) {
const used = process.memoryUsage()
const rss = Math.round(used.rss / 1024 / 1024)
const heap = Math.round(used.heapUsed / 1024 / 1024)
console.log(`[${label}] RSS: ${rss}MB Heap: ${heap}MB`)
}
// Wrap each capture call
logMemory("before-capture")
await captureScreenshot(url)
logMemory("after-capture")Run 100 sequential requests. If RSS grows by more than roughly 50MB total, you have a leak. If RSS stays flat but heap grows, the leak is in your Node.js application code, not Chrome. Those have different fixes.
For containerized deployments, watch from outside the container:
# Single snapshot
docker stats --no-stream --format "{{.Name}}: {{.MemUsage}}" <container_id>
# Stream every 5 seconds
watch -n 5 "docker stats --no-stream --format '{{.Name}}: {{.MemUsage}}' <container_id>"A clean browser should hold steady after warmup. Memory that increases linearly with request count points directly at the headless Chrome memory leak, not the Node heap.
Step 2: Fix Unclosed Pages
This is the most common cause. Every code path that opens a page must close it, including all error paths. The fix is a try/finally block that wraps the entire page lifecycle:
const puppeteer = require("puppeteer")
// browser is a shared instance, initialized elsewhere
async function capture(url) {
let page
try {
page = await browser.newPage()
// Block images and fonts to cut memory per page
await page.setRequestInterception(true)
page.on("request", (req) => {
if (["image", "font"].includes(req.resourceType())) {
req.abort()
} else {
req.continue()
}
})
await page.goto(url, { waitUntil: "networkidle2", timeout: 30_000 })
return await page.screenshot({ type: "png" })
} finally {
// Executes whether the try block succeeded, timed out, or threw
if (page && !page.isClosed()) {
await page.close().catch(() => {})
}
}
}The finally block executes unconditionally. Even if page.goto() throws a TimeoutError or a network error, the page closes. Add the .catch(() => ) onpage.close() itself: if the browser process has already crashed, close() will throw, and you do not want that to swallow the original error.
Step 3: Limit Concurrent Pages
Chrome allocates a separate renderer process per page. Ten concurrent captures mean ten renderer processes, each consuming 100-300MB of RAM. Without a concurrency limit, a load spike causes an immediate OOM.
class Semaphore {
constructor(limit) {
this.limit = limit
this.active = 0
this.queue = []
}
async acquire() {
if (this.active < this.limit) {
this.active++
return
}
await new Promise((resolve) => this.queue.push(resolve))
this.active++
}
release() {
this.active--
if (this.queue.length > 0) {
this.queue.shift()()
}
}
}
const sem = new Semaphore(3) // max 3 concurrent Chrome pages
async function captureWithLimit(url) {
await sem.acquire()
try {
return await capture(url)
} finally {
sem.release()
}
}Three concurrent pages is a conservative starting point for a container with 2GB RAM. Tune the number down if you still see memory pressure at peak load, and up only after measuring that headroom exists.
Step 4: Recycle the Browser Process
Even with correct page cleanup, Chrome's internal caches grow over time. Scheduled browser recycling keeps this bounded:
const puppeteer = require("puppeteer")
let browser = null
let captureCount = 0
const RECYCLE_AFTER = 200 // restart browser every 200 captures
async function getBrowser() {
if (!browser || captureCount >= RECYCLE_AFTER) {
if (browser) {
try { await browser.close() } catch (_) {}
}
browser = await puppeteer.launch({
args: [
"--no-sandbox",
"--disable-setuid-sandbox",
// Move shared memory to /tmp (Docker default /dev/shm is 64MB)
"--disable-dev-shm-usage",
// Cap Chrome disk cache at 64MB to limit in-memory cache growth
"--disk-cache-size=67108864",
"--media-cache-size=0",
],
})
captureCount = 0
}
captureCount++
return browser
}The --disable-dev-shm-usage flag is required in Docker. Chrome defaults to /dev/shm for shared memory; most Docker containers cap it at 64MB, causing Chrome to crash under normal rendering load. This flag moves shared memory to /tmp instead.
The --disk-cache-sizeflag bounds Chrome's disk cache to 64MB. Without it, Chrome uses up to 80MB of in-memory cache for warm-started content, and this grows unbounded in a long-running process.
Troubleshooting
Memory grows even with page.close() called: Check whether you are using browser contexts via browser.createBrowserContext(). Each context has its own cookie store and cache. Close the context after the page, not just the page.
OOM in Docker with plenty of host RAM: The container has a memory limit lower than what Chrome needs. Set it explicitly:docker run --memory=2g --memory-swap=2g. Setting swap equal to the memory limit disables swap, which causes Chrome to OOM-kill cleanly rather than thrash disk.
Leak only appears under concurrent load: The page.close() call is inside a callback that races with a request timeout handler. Move the finally block to the top-level handler function, not inside a nested async callback, so it executes regardless of which promise settles first.
Process restarts fix the leak temporarily, but it returns within hours: You may have a genuine Chrome heap leak rather than a resource management issue. Pin Chromium to the version that preceded the regression, or shorten the browser recycle interval until the leak rate is acceptable.
When to Stop Debugging
Three signals that continuing to patch the headless Chrome memory leak is no longer the right call:
- You have applied all four fixes and RSS still grows past 1GB under normal production load.
- Your team is spending more than a couple of hours per sprint triaging browser reliability incidents instead of product work.
- You are running Chrome in a serverless or edge environment where a process restart costs a 5-10 second cold start.
A screenshot API handles browser process management, memory, and concurrency on its own infrastructure. Your code makes an HTTP request. The headless Chrome memory leak is no longer your operational concern.
The Rendex free tier is 100 calls per month with no credit card required. The quickstart takes about two minutes to your first rendered image. If you want to see a full cost comparison between self-hosted Chrome and an API before switching, the Puppeteer vs screenshot API cost breakdown covers infrastructure, maintenance time, and when DIY wins.
Next Steps
If the fixes in this guide resolved your issue, two things worth doing: run a 24-hour soak test under production load to confirm the fix holds, and add the RSS logging from Step 1 to your monitoring stack as a permanent metric. Memory leaks that are fixed once can reappear after a Chromium version bump.
If you decide to offload rendering, the free screenshot tool is a quick way to verify your captures match what a managed browser produces without writing any code first.