You wrote the PDF feature in ten minutes. npm i puppeteer, page.setContent(html), page.pdf(). It worked perfectly on your laptop.
Then you ran vercel deploy, and it died.
Error: Could not find Chromium (rev. 1108766). This can occur if either
1. you did not perform an installation before running the script
2. your cache path is incorrectly configured
If you've been here, you know the next three hours: swapping in @sparticuz/chromium, bumping the function memory, hitting the 250 MB unzipped bundle limit, fighting cold starts, and discovering your fonts render as tofu boxes in production but not locally.
Why it breaks
Headless Chrome was never designed to live inside a serverless function. The conflicts are structural, not a config you missed:
- Bundle size. Chromium is ~170 MB compressed. Vercel/Lambda cap the unzipped function at 250 MB. You're over budget before your own code.
- Cold starts. Spinning up a fresh Chromium per invocation adds seconds. Under load you're launching dozens of browsers.
- Memory. Chromium wants 1 GB+ for anything non-trivial. Complex documents OOM and the function just... dies, with a useless log line.
- Missing system libraries and fonts. The base image doesn't ship the libs Chromium links against, or the fonts your CSS references. "Works on my machine" in its purest form.
- Timeouts.
waitUntil: "networkidle"plus a slow asset and you've blown the function's execution limit.
The hacks, and why they don't last
@sparticuz/chromium — a stripped Chromium build that fits the bundle. It works, until a Chromium version bump breaks the pairing with your puppeteer-core, or a heavier document OOMs anyway. You now own a fragile binary-compatibility problem forever.
A second always-on server just for Chrome. Now you're running and paying for a box 24/7, building your own queue, and reinventing retries — to render a PDF.
Both are the same mistake: running a browser inside infrastructure that doesn't want one.
The fix: don't run Chrome at all
Move the render off your function. You send HTML, a hosted service runs Chromium on infrastructure built for it, you get a PDF back. Your serverless function stays tiny and stateless.
Here's the whole thing with Leafwright:
// app/api/invoice/route.ts — runs fine on Vercel, no Chromium in your bundle
export async function POST(req: Request) {
const { html } = await req.json();
const res = await fetch("https://leafwright.co/api/v1/pdfs", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.LEAFWRIGHT_API_KEY}`,
"Content-Type": "application/json"
},
body: JSON.stringify({
source: { type: "html", html },
delivery: { return: "url" }
})
});
const { download_url } = await res.json();
return Response.json({ download_url });
}
No puppeteer dependency. No @sparticuz/chromium. No memory tuning. The PDF comes back as a signed URL (or raw bytes, if you'd rather stream it), and every render is a tracked job with logs you can actually read when something fails.
"But I render the same invoice every time"
Then don't ship HTML on every request — publish a template once and render it with JSON:
await fetch("https://leafwright.co/api/v1/templates/tmpl_invoice/render", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.LEAFWRIGHT_API_KEY}`,
"Content-Type": "application/json"
},
body: JSON.stringify({ data: { client: "Northwind", total: "$16,000" } })
});
And if you don't want to hand-write print CSS at all, AI can draft the template from a prompt and even repair it from the render logs when a layout breaks — but that's a different post.
The point
Generating a PDF should be one API call, not an infrastructure project. If you're staring at a Chromium error in your Vercel logs right now, you can have a working PDF in about five minutes:
→ Start free — 100 PDFs/month, no credit card
Built on Next.js or Lambda? The example above is the entire integration. Swap in your HTML and ship.