Back to Blog
Deep Dive

Why Puppeteer Keeps Crashing in Production

2026-01-24·3 min read
puppeteerchromiummemoryproduction

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.

typescript
// 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

typescript
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.

typescript
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.

typescript
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:

typescript
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.

typescript
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.

Check the SDK →

Ready to generate PDFs?

Zero data retention. GDPR compliant.

Start Free TrialZero data retention · GDPR