It didn't announce itself dramatically. No alarms, no pages, no production fire.
Just a support ticket on a Tuesday morning: "Hey, I signed up yesterday and never received my invoice. Can you resend it?"
We checked. The invoice had never generated. We restarted the PDF service, the next few invoices went out fine, and we closed the ticket and moved on. A week later, another one. Then two in the same day. Then we pulled the logs and realised this had been happening quietly for over a month — a small but steady trickle of failed invoice generations that nobody had noticed because most customers just didn't say anything.
We were running Puppeteer in a Docker container to generate PDF invoices — a setup that had worked perfectly in staging, worked fine in the first couple of months of production, and then slowly, quietly, started falling apart.
The culprit: a memory leak we couldn't fully pin down. The Chromium process would balloon over time, the pod would get OOM-killed, and invoice generation would silently fail until the container restarted itself. By then, the moment had passed and no retry logic was in place to catch the ones that dropped.
We'd fix it, it'd come back. Fix it again, come back again. After the third round of the same investigation — bumping container RAM, trying --single-process, setting up puppeteer-cluster, rewriting the pool logic — I decided I was done managing a browser as a production API server. So I built something better.
This is the story of what I built, why the architecture is different, and the code you can use to do the same thing.
Why Puppeteer Breaks in Production
Puppeteer is a great tool. It's the right choice for browser automation, end-to-end testing, and scraping pages that require JavaScript execution. It is not the right choice for generating PDFs at scale in a production environment.
Here's why:
It runs a full browser. Chromium isn't a PDF renderer — it's a browser. When you launch Puppeteer, you're booting up an entire browser engine just to render HTML and export it. That's enormous overhead for what is ultimately a document conversion task.
It leaks memory. This is widely documented and still not fully solved. Long-running Puppeteer processes accumulate memory across page loads, especially if you're reusing browser instances (which you have to do for performance). The longer your service runs, the more memory it holds, until your container dies.
Scaling it is painful. To handle concurrent PDF requests, you need a pool of browser instances — enter puppeteer-cluster or similar libraries. Now you're managing browser lifecycle, concurrency limits, error recovery, and zombie processes. That's a lot of operational complexity for a feature that should be invisible.
Cold starts are slow. Launching a new Chromium instance takes 1–3 seconds. Even with pooling, under burst load your response times spike.
The fundamental issue is a mismatch: you want a document generation service, but you're operating a browser fleet.
What I Built Instead
I built ProdaDoc — a REST API that takes HTML (or structured JSON) and returns a valid PDF. No browser on your server. No pool to manage. No memory leaks. Just an HTTP request.
The architecture is simple: your application POSTs HTML to the API, the API renders and returns binary PDF data, and you save or stream it however you need. One request, one response.
Here's the most basic example:
const response = await fetch(
'https://prodadoc-production.up.railway.app/api/v1/pdf/from-html',
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': 'YOUR_API_KEY'
},
body: JSON.stringify({
html: '<h1>Invoice #001</h1><p>Amount due: $4,000</p>',
filename: 'invoice-001.pdf'
})
}
);
const pdfBuffer = await response.arrayBuffer();
// pdfBuffer is a valid PDF — save it, stream it, email it
Average response time: under 400ms. No Chromium. No leak.
A Real Invoice Generation Example
The basic HTML endpoint is useful, but for invoices specifically, there's a template endpoint that handles the layout for you. You send structured JSON with your line items and it returns a properly formatted invoice PDF:
const response = await fetch(
'https://prodadoc-production.up.railway.app/api/v1/pdf/from-template',
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': 'YOUR_API_KEY'
},
body: JSON.stringify({
template: 'invoice',
data: {
companyName: 'Acme Corp',
clientName: 'Client Ltd',
invoiceNumber: 'INV-2026-041',
issueDate: '2026-03-13',
dueDate: '2026-04-13',
items: [
{
description: 'Web application development',
quantity: 40,
unitPrice: 150,
amount: 6000
},
{
description: 'UI/UX design',
quantity: 12,
unitPrice: 120,
amount: 1440
}
],
subtotal: 7440,
tax: 744,
total: 8184,
currency: 'USD'
}
})
}
);
const pdf = await response.arrayBuffer();
No HTML to write. No CSS to wrestle with. No layout bugs to fix. Just a clean, professional invoice PDF.
The Python Version
For anyone coming from a Python backend:
import requests
response = requests.post(
'https://prodadoc-production.up.railway.app/api/v1/pdf/from-html',
headers={'x-api-key': 'YOUR_API_KEY'},
json={
'html': '''
<html>
<body style="font-family: Arial, sans-serif; padding: 40px;">
<h1 style="color: #1A56DB;">Invoice #2026-041</h1>
<p>Client: Acme Corp</p>
<p>Amount due: <strong>$8,184.00</strong></p>
<p>Due date: April 13, 2026</p>
</body>
</html>
''',
'filename': 'invoice-2026-041.pdf',
'options': {
'format': 'A4',
'margin': {
'top': '20mm',
'bottom': '20mm',
'left': '15mm',
'right': '15mm'
}
}
}
)
with open('invoice-2026-041.pdf', 'wb') as f:
f.write(response.content)
print(f"Generated: {len(response.content)} bytes")
Migrating from Puppeteer
If you're currently running Puppeteer for PDF generation, the migration is mostly a find-and-replace. Here's what the before and after looks like:
Before (Puppeteer):
const puppeteer = require('puppeteer');
async function generatePDF(html) {
const browser = await puppeteer.launch({
args: ['--no-sandbox', '--disable-setuid-sandbox'],
headless: true
});
try {
const page = await browser.newPage();
await page.setContent(html, { waitUntil: 'networkidle0' });
const pdf = await page.pdf({
format: 'A4',
printBackground: true,
margin: { top: '20mm', bottom: '20mm', left: '15mm', right: '15mm' }
});
return pdf;
} finally {
await browser.close(); // Hope this doesn't leak
}
}
After (ProdaDoc):
async function generatePDF(html) {
const response = await fetch(
'https://prodadoc-production.up.railway.app/api/v1/pdf/from-html',
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': process.env.PRODADOC_API_KEY
},
body: JSON.stringify({
html,
options: {
format: 'A4',
printBackground: true,
margin: { top: '20mm', bottom: '20mm', left: '15mm', right: '15mm' }
}
})
}
);
if (!response.ok) {
const error = await response.json();
throw new Error(`PDF generation failed: ${error.error.message}`);
}
return Buffer.from(await response.arrayBuffer());
}
Same function signature. Same output. No browser. No leak.
URL-to-PDF (Bonus)
One feature that comes in handy for compliance and archiving — you can also convert a live URL directly to PDF:
const response = await fetch(
'https://prodadoc-production.up.railway.app/api/v1/pdf/from-url',
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': 'YOUR_API_KEY'
},
body: JSON.stringify({
url: 'https://yourapp.com/invoice/INV-2026-041',
filename: 'invoice-archived.pdf'
})
}
);
Useful if you already render invoices as web pages and just need a PDF snapshot for your records.
Merging PDFs
For legal and finance workflows that require bundling documents, there's a merge endpoint:
// First, generate your individual PDFs and get their base64 data
// Then merge them in a single call
const response = await fetch(
'https://prodadoc-production.up.railway.app/api/v1/pdf/merge',
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': 'YOUR_API_KEY'
},
body: JSON.stringify({
pdfs: [
base64PDF1,
base64PDF2,
base64PDF3
],
filename: 'contract-bundle.pdf'
})
}
);
const mergedPDF = await response.arrayBuffer();
Up to 20 PDFs merged in one API call.
What I Learned Building This
A few things stood out during the build that might be useful if you're building something similar:
Validate everything at the edge. All inputs go through Zod schemas before anything executes. This sounds obvious, but it eliminates an entire class of runtime errors that would otherwise surface as cryptic failures.
SSRF protection is non-negotiable for URL-based inputs. If you're accepting URLs and fetching them server-side, you must validate that the URL doesn't point to internal network resources. One missed check and you've handed attackers a proxy into your infrastructure.
Timing-safe auth matters. Standard string comparison for API keys is vulnerable to timing attacks. Use crypto.timingSafeEqual() in Node.js or the equivalent in your language.
Return structured errors. Every error response follows the same shape:
{
"error": {
"code": "VALIDATION_ERROR",
"message": "HTML content is required",
"retryable": false
}
}
A consistent error format means clients can handle failures programmatically without parsing arbitrary error strings.
Try It
ProdaDoc is live. There's a free tier for testing — no credit card required.
If you're currently running Puppeteer in production and you've hit any of the issues described here, I'd genuinely like to hear about your setup. Drop a comment below — what are you generating PDFs for, and what's been the biggest pain point?
And if you just want to test it: click here
Built with Node.js, deployed on Railway. Questions about the implementation? Ask below.
Top comments (0)