Puppeteer PDF Alternative: When Managed APIs Beat Self-Hosted
Puppeteer-based PDF generation usually fails in production because Chrome instances are heavy (150–300MB), take seconds to start, and often don't close cleanly—leading to memory growth and timeouts after dozens to hundreds of renders. Managed APIs replace the binary with a stateless rendering service so you send JSON and receive PDFs reliably without managing Chrome, timeouts, or memory.
Why Puppeteer Fails in Production
Puppeteer delegates rendering to a full Chrome binary. Each render often spawns a browser process (or many processes per tab), consuming 150–300MB of disk and 200–500MB of RAM while running. When browser.close() doesn't fully terminate DevTools websockets, timers, or pending promises, references remain on the Node.js event loop and memory accumulates. Over time you see CPU spikes, increasing GC activity, and eventually OOMs or process crashes.
Why It Gets Worse at Scale
Serverless platforms add constraints that amplify these problems:
- Function timeouts (e.g., Vercel hobby: 10s, pro: 60s) mean warm-up + render often exceeds limits on complex docs.
- Lambda cold starts combined with a 150–300MB Chrome binary add several seconds of latency per cold invocation.
- Memory budgets (512MB–3GB) limit concurrency: multiple simultaneous renders quickly exhaust available memory.
- Container reuse can accumulate leaked memory across invocations, causing slow failure modes.
Common Workarounds (And Why They Don't Work)
- Connection pooling (puppeteer-cluster) reduces browser startups but doesn't eliminate leaks—the pool still holds state and eventually degrades after 100–1000 renders.
- Increasing memory/timeouts only delays failure and increases cost.
- Moving to Docker/EC2 gives more control but requires you to build and maintain rendering infrastructure (monitoring, autoscaling, updates, security).
The Alternative Approach
Architecture contrast:
- App → Puppeteer → Chrome → PDF (self-hosted)
- App → Managed Rendering API → PDF (stateless)
Benefits of a managed API:
- No Chrome binary in your deploys
- Stateless requests (no long-lived pools to leak memory)
- Predictable per-render latency and throughput
- Lower operational burden (updates, security, scaling)
Before (Puppeteer - memory leaks, timeouts)
// before-puppeteer.js
// Demonstrates a simple Puppeteer render that can leak over time.
import puppeteer from 'puppeteer'
async function render(html, outPath) {
// On many servers this launches a 150-300MB binary and uses ~200-500MB RAM
const browser = await puppeteer.launch({ args: ['--no-sandbox'] })
try {
const page = await browser.newPage()
await page.setContent(html, { waitUntil: 'networkidle0' })
await page.pdf({ path: outPath, format: 'A4' })
} finally {
// browser.close() is async and can fail to fully terminate DevTools sockets
// If promises/timers remain referenced, memory can stick around
await browser.close()
}
}
// Usage example
// node before-puppeteer.js
// Note: at ~50-200 renders you'll often see memory increase and eventual crashes
After (Hundred Docs-style API - no infrastructure)
// after-managed-api.js
// Send a templateId and JSON payload to a rendering API. Node 18+ has global fetch.
const API_URL = 'https://api.hundredocs.com/v1/pdf'
const API_KEY = process.env.HUNDRED_DOCS_API_KEY || 'your-api-key'
async function renderPdf(templateId, data) {
const res = await fetch(API_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-API-Key': API_KEY
},
body: JSON.stringify({ templateId, data })
})
if (!res.ok) {
const err = await res.text()
throw new Error(`Render failed: ${res.status} ${err}`)
}
const buffer = Buffer.from(await res.arrayBuffer())
// Write to disk or return from serverless function
await Deno.writeFile('./out.pdf', buffer)
}
// Usage example
// node after-managed-api.js
// Benefits: no Chrome binary, sub-second warm responses, and no long-lived processes to manage
When to Use Puppeteer vs Managed APIs
- Puppeteer: good for low-volume (<10 PDFs/day) jobs where you need full control over the browser and rendering engine, and you can manage infrastructure.
- Managed API: preferable at 100+ PDFs/day, for serverless-hosted apps, or when you want predictable costs and less operational overhead.
Migration Path
- Identify templates and data shape (move HTML snippets into template variables).
- Extract rendering logic to a thin adapter that calls a rendering API with templateId + JSON.
- Run both systems in parallel for a period (compare PDF diffs, performance).
- Switch traffic and turn off self-hosted renderers once confidence is high.
Related Guides
- Puppeteer PDF Memory Leak in Production - Deep dive into leaks
- Serverless PDF Generation: Why Headless Browsers Break - Architectural context
- JSON to PDF: Separating Data from Design - Templates and JSON mapping
- Invoice PDF Generation API - Example use case
- Self-Hosted PDF Generation vs Cloud API: The Real Cost of 'Free' - Comprehensive comparison of self-hosting versus managed API solutions.
- Best PDF Generation Libraries for Node.js - A broader look at the Node.js PDF ecosystem.
- Why Headless Chrome Crashes in Production - A deep dive into the stability issues of self-hosted rendering.
Tools like Hundred Docs handle Chrome instance management and memory cleanup for you at the rendering layer, letting you send JSON and receive PDFs without managing infrastructure.