When running Puppeteer in production for weeks, the pattern is predictable: the container’s resident memory slowly climbs until the kernel invokes the OOM killer. Many teams blame a specific Node.js leak, but the reality is more complex. The memory leak is not in your Node process, but in Chromium’s child processes that are not properly closed, in the accumulated state of a browser that lives too long, and in orphaned processes that the operating system still counts. This article addresses the three causes, the practical fixes, and, most importantly, the moment when it makes more sense to delegate the browser execution to an external service, freeing your infrastructure of that burden. At Q2BSTUDIO, a custom software development company, we have seen this scenario in dozens of projects and know that the right approach combines code hygiene, concurrency limits, and an honest assessment of whether you really need Chromium.
The three layers of the problemThe first trap is measuring only process.memoryUsage(). You see the Node heap flat and think there is no leak, while the container’s RSS rises uncontrollably. That gap is the whole story. Chromium runs a tree of processes: the main browser, renderers per page, and GPU processes. When you don’t close a page —because an exception, a timeout, or an early return skips the finally— that renderer process stays retained. Multiply that by thousands of captures and you have a clear leak. The second layer is the long-lived browser. Keeping a single Chromium instance alive for the container’s entire life seems efficient, but after thousands of navigations it accumulates caches, detached DOM nodes, and listeners that are never fully freed. The third layer is zombie processes: a capture that hangs or crashes leaves an orphaned Chromium process that the cgroup still counts, even if your application doesn’t know it. The result is a container that says “out of memory” while your app claims to use very little.
Practical fixes: close, recycle, bound, and reapThe most immediate correction is to wrap every capture in a try/finally block. No matter if the function throws, times out, or returns successfully: the page must be closed. The finally ensures page.close() is always executed. The second step is to treat the browser as disposable. Instead of keeping one immortal, recycle it after a fixed number of jobs (e.g., 200 captures). This amortizes the startup cost without letting state accumulate. The third group of measures bounds the blast radius: launch Chromium with --disable-dev-shm-usage to avoid the small /dev/shm in containers, limit the V8 heap with --js-flags='--max-old-space-size=512', and set a concurrency cap matching available RAM. Finally, run your process under an init (--init in Docker or tini) so orphans are reaped, and force-kill after a global timeout so no hanging page leaves a Chromium alive forever.
How to identify the real sourceStop guessing. Measure the container’s total RSS and the number of chrome processes. If both rise while the Node heap stays stable, the problem is in unclosed pages, an unrecycled browser, or zombies. A simple counter of pages opened vs. closed will tell you if you are leaking pages. You can also take two heap snapshots of a browser context before and after an hour of representative traffic and look for detached DOM nodes or retained listeners. That trace leads you directly to a page.on(...) handler you forgot to remove or to the website you are capturing. In cloud environments like AWS or Azure, where every service competes for resources, such a leak can spike scaling costs. That is why at Q2BSTUDIO we integrate cloud AWS/Azure practices and process monitoring to detect these leaks before they reach production.
What if you don’t need Chromium at all?Here comes the uncomfortable question. If your only goal is to obtain an image of a URL, all this effort —memory hygiene, process recycling, zombie reaping, container tuning— is infrastructure for a task that could be solved with a single HTTP request to a screenshot API. Hosted services run Chromium for you and return the image directly, without you having to launch, close, or monitor anything. The operational cost disappears: no processes to reap, no memory graphs to watch, no unclosed pages. For teams building custom applications, artificial intelligence, or AI agents that need to visually understand a webpage, outsourcing the capture lets them focus on business logic and integration with BI/Power BI or cybersecurity systems. At Q2BSTUDIO we have helped clients migrate from a self-managed Puppeteer cluster to an architecture where the capture is just another resource, orchestrated from AI or automation platforms. The decision is not binary: Puppeteer remains the right tool when you need to interact with the page (click, fill forms, extract structured data). But if you only want a picture, the most durable fix is not to run the browser yourself.
Conclusion: three problems, one coat, and a way outPuppeteer memory leaks in production are not a single bug: they are unclosed pages, an unrecycled browser, and zombie processes. They are fixed with finally, recycling, concurrency limits, and an init process. But after applying all that, it is still worth asking whether the effort of maintaining a Chromium fleet is proportional to the value it brings. When the answer is “I only need a capture,” the most robust solution is to delegate. That way you stop watching the RSS and start building on a foundation that does not leak.





