Your script works. You've tested it a hundred times. Then it hits production and everything falls apart.
Memory spikes to 2GB. The process hangs. Your monitoring lights up at 3am. Sound familiar?
The Real Problem
Puppeteer isn't broken. Chromium is just hungry.
Every page.goto() spins up a full browser context. That means:
- V8 JavaScript engine
- Blink rendering engine
- Network stack
- GPU compositor (yes, even headless)
A single page load can eat 150-300MB. Run 10 concurrent pages? You're at 2GB easy.
// This looks innocent
const browser = await puppeteer.launch()
for (const url of urls) {
const page = await browser.newPage()
await page.goto(url)
await page.pdf({ path: `output-${i}.pdf` })
// Forgot to close the page? Memory leak.
}That loop creates pages but never closes them. Classic memory leak.
Why It Works Locally
Your MacBook has 16GB RAM and swap. It hides the problem.
Production containers typically have 512MB-2GB. No swap. Hard limits.
When you hit the ceiling, the kernel kills your process. No warning. No graceful shutdown. Just gone.
The Fixes
1. Always Close Pages
const page = await browser.newPage()
try {
await page.goto(url)
await page.pdf({ path: 'output.pdf' })
} finally {
await page.close() // Always. Every time.
}2. Limit Concurrency
Don't process 50 PDFs at once. Use a queue.
import PQueue from 'p-queue'
const queue = new PQueue({ concurrency: 3 })
await Promise.all(urls.map((url) => queue.add(() => generatePdf(url))))3. Restart the Browser
Chromium leaks memory over time. Even with perfect code, it accumulates garbage.
let requestCount = 0
const MAX_REQUESTS = 100
async function generatePdf(html: string) {
if (requestCount >= MAX_REQUESTS) {
await browser.close()
browser = await puppeteer.launch()
requestCount = 0
}
requestCount++
// ... generate PDF
}4. Use Browser Args
Disable features you don't need:
const browser = await puppeteer.launch({
args: [
'--disable-gpu',
'--disable-dev-shm-usage',
'--disable-setuid-sandbox',
'--no-sandbox',
'--single-process', // Reduces memory, but crashes take down everything
],
})The --disable-dev-shm-usage flag is critical. Docker containers have limited shared memory by default. Without this flag, Chromium crashes randomly.
The Math Doesn't Work
Here's the uncomfortable truth: Puppeteer in production is fighting physics.
| Container Size | Chromium Base | Available for Pages | Max Concurrent |
|---|---|---|---|
| 512MB | ~200MB | ~300MB | 1-2 |
| 1GB | ~200MB | ~800MB | 3-4 |
| 2GB | ~200MB | ~1.8GB | 6-8 |
And that's assuming nothing else runs in the container.
Serverless makes it worse. Cold starts mean launching Chromium from scratch—3-5 seconds minimum. Your users are waiting.
When to Give Up on Self-Hosting
If you're spending more time debugging Chromium than building your product, stop.
Signs you've hit the wall:
- Memory tuning is a weekly task
- You're running dedicated "PDF servers"
- Cold starts are killing your UX
- You've read the Puppeteer issues page more than the docs
At that point, the infrastructure cost exceeds what an API would cost.
The API Alternative
NeatPDF runs optimized Chromium pools on dedicated infrastructure. You send HTML, you get a PDF. No browser management. No memory tuning.
import { NeatPDF } from '@neatpdf/sdk'
const client = new NeatPDF({ apiKey: process.env.NEATPDF_API_KEY })
const pdf = await client.generate({
html: '<h1>Invoice #1234</h1>',
format: 'A4',
})That's it. No Puppeteer. No Chromium. No 3am pages.
The SDK is typed end-to-end. If it compiles, it works.