Migrate from Puppeteer to Papyr
Puppeteer PDF generation works fine locally. Then you deploy to Linux and get blank PDFs, missing fonts, and a Dockerfile that takes 10 minutes to build. Papyr is one HTTP call. Your HTML goes in, a PDF comes back, and Chromium is off your server.
Why Puppeteer is painful for PDFs
Blank PDFs on Linux
Puppeteer launches a real Chromium instance. On Alpine Linux (the default Docker base), it's missing dozens of system libraries. The result is a blank PDF or a crash — and the error message tells you nothing useful.
400 MB Docker images
A minimal Node app with Puppeteer weighs ~450 MB. Add Chromium system deps to Alpine and you're at 600+ MB. Slow builds, slow cold starts, bloated image registry.
Memory leaks and zombie processes
If a page crashes or your code throws before browser.close(), the Chromium process keeps running. A few leaked browsers and your server runs out of memory. You end up writing retry logic around something that shouldn't need it.
Font rendering inconsistency
Fonts that look right in dev look wrong in production. System font stacks differ between macOS and Linux. Google Fonts require network access at render time. Certificates need to be trusted in the container.
waitUntil guesswork
networkidle0 waits for all network requests to settle — but it waits up to 30 seconds if anything keeps polling. networkidle2 cuts off early. You end up with flaky PDFs depending on which external resources your HTML loads.
Migration guide
Replace your Puppeteer function with one that calls Papyr. The signature is the same — HTML in, Buffer out.
Before (Puppeteer)
const puppeteer = require("puppeteer");
async function htmlToPdf(html) {
const browser = await puppeteer.launch({
args: ["--no-sandbox", "--disable-setuid-sandbox"],
});
const page = await browser.newPage();
await page.setContent(html, { waitUntil: "networkidle0" });
const pdf = await page.pdf({ format: "A4" });
await browser.close();
return pdf;
}After (Papyr) — Node.js
async function htmlToPdf(html) {
const res = await fetch("https://api.getpapyr.dev/v1/render/pdf", {
method: "POST",
headers: {
"Authorization": `Bearer ${process.env.PAPYR_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ html, options: { format: "A4" } }),
});
if (!res.ok) throw new Error(`PDF generation failed: ${res.status}`);
return Buffer.from(await res.arrayBuffer());
}Same function signature. Callers need no changes.
After (Papyr) — Python
import os
import requests
def html_to_pdf(html: str) -> bytes:
res = requests.post(
"https://api.getpapyr.dev/v1/render/pdf",
headers={"Authorization": f"Bearer {os.environ['PAPYR_API_KEY']}"},
json={"html": html, "options": {"format": "A4"}},
)
res.raise_for_status()
return res.contentDockerfile before and after
# Before: Chromium + system deps = ~400 MB extra
FROM node:20-alpine
RUN apk add --no-cache \
chromium nss freetype harfbuzz ca-certificates ttf-freefont
ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium-browser# After: nothing extra needed
FROM node:20-alpine
# No Chromium. No system fonts. No workarounds.Options mapping
| Puppeteer page.pdf() option | Papyr option |
|---|---|
| format: "A4" | options.format: "A4" |
| landscape: true | options.landscape: true |
| margin: { top: "20mm" } | options.margin.top: "20mm" |
| printBackground: true | options.printBackground: true |
| width: '210mm' | options.format: "A4" (use named sizes) |
What you gain
Ready to remove Puppeteer?
Free tier includes 100 documents/month. No credit card required.