Generating a PDF from HTML sounds trivial — open a page, call page.pdf(), done. On serverless is where it gets tricky. The Chromium binary weighs 50MB, exceeds many platforms' limits, and booting it on every invocation is slow. This is the architecture we landed on at ReclamaAI after three iterations.
Why we didn't use a third-party service
The first thing I tried was PDFShift, DocRaptor and Browserless. They all work, but costs grow fast when you generate thousands of documents per month, and none gave me the control I needed over fonts, custom headers/footers, and embedded signatures. I decided to internalize it.
The final architecture
ReclamaAI uses @sparticuz/chromium instead of the official Puppeteer package. It's a Chromium build specifically compressed for AWS Lambda and compatible with Vercel functions. It weighs 45MB instead of 280MB, which fits us just under Vercel's 50MB function limit without paying the enterprise tier.
The generation function looks like this: it receives HTML prepared by the template engine, launches Chromium headless, waits for networkidle0 (important so fonts load), calls page.pdf({ format: "Letter", printBackground: true, margin: ... }), and returns the buffer. The buffer uploads to S3 in another promise, not in the same function — to keep total time under control.
The cold start
The first invocation takes 6-8 seconds (booting Chromium cold). Subsequent ones, while the function is warm, take 1.5-2 seconds. If the user is waiting at the screen, 8 seconds is forever.
Solution: generation is asynchronous from day one. The user clicks "generate", the frontend gets a job_id, and a worker (BullMQ + Redis) processes the job. The frontend polls every 2 seconds. When the document is ready, we show a preview and a download button. To the user, the cold start's 8 seconds feel like a legitimate loading state, not a frozen page.
Fonts: the detail that almost broke me
Serverless Chromium doesn't ship with system fonts. If your HTML uses font-family: Arial, the PDF renders with Chromium's fallback font, which is awful. For legal documents this is unacceptable.
The solution: we pack the fonts we need as inline base64 in the template CSS, via @font-face. The HTML weighs more (~200KB extra) but guarantees the render is identical in any environment. For ReclamaAI we load Inter (body) and Times (legal citation by convention).
Real costs
Vercel Hobby is enough to get started. When you start bumping the Function Execution Time limit (60s on Hobby), it's worth moving to a paid plan for longer functions. At a scale of thousands of documents per month, the Vercel functions cost stays modest compared to an external PDF-as-a-service provider at list price. The difference, multiplied by month, pays back the in-house implementation sprint quickly.
What we broke in production
One time Vercel updated its Node runtime and the @sparticuz/chromium binary stopped working for an hour. Lesson learned: have a fallback. Now if Puppeteer fails 3 times in a row, we queue the document for later regeneration and send the user an automatic email: "your document is in queue, you'll get it in under 30 minutes". We've never had to use the fallback more than a couple of times, but knowing it exists lets me sleep.