PDF generation vs HTML-to-PDF: a practical comparison for developers

A hands-on comparison of native PDF generation and HTML-to-PDF rendering, with code, tradeoffs, and guidance on when to pick each approach.

pdfhtml-to-pdfnode.jspuppeteerpdfkitdocument-generation

PDF generation vs HTML-to-PDF: a practical comparison for developers

If you've ever had to generate an invoice, a report, or a contract from a backend service, you've hit the same fork in the road: build the PDF directly using a library like PDFKit or ReportLab, or render an HTML template and convert it to PDF with something like Puppeteer, WeasyPrint, or wkhtmltopdf.

Both approaches work. Both have real tradeoffs. This post breaks down when each one makes sense, with code and honest observations from production use.

What "PDF generation" actually means

"Native" PDF generation means constructing the document programmatically — placing text, shapes, and images at explicit coordinates on a page. You call APIs that map directly to PDF primitives.

Here's a minimal example with PDFKit in Node.js:

ts
import PDFDocument from "pdfkit"; import fs from "fs"; const doc = new PDFDocument({ size: "A4", margin: 50 }); doc.pipe(fs.createWriteStream("invoice.pdf")); doc.fontSize(20).text("Invoice #INV-001", 50, 50); doc.fontSize(12).text("Amount due: $1,240.00", 50, 90); doc.moveTo(50, 120).lineTo(545, 120).stroke(); doc.end();

You get precise control, small binaries, and no browser dependency. The downside: you're doing layout by hand. Anything nontrivial — tables that wrap, headers on every page, multi-column layouts — becomes tedious quickly.

What "HTML-to-PDF" actually means

HTML-to-PDF means writing your document as HTML/CSS and letting a rendering engine produce the PDF. The most common approach is running headless Chromium via Puppeteer:

ts
import puppeteer from "puppeteer"; const browser = await puppeteer.launch(); const page = await browser.newPage(); await page.setContent(` <html> <body style="font-family: sans-serif; padding: 40px;"> <h1>Invoice #INV-001</h1> <p>Amount due: $1,240.00</p> </body> </html> `); await page.pdf({ path: "invoice.pdf", format: "A4" }); await browser.close();

You get the entire CSS ecosystem, real layout engines, web fonts, SVG, and the ability to preview your template in a browser during development. The downside: you're now shipping a headless browser, which is a substantial operational commitment.

The practical comparison

Developer experience

HTML-to-PDF wins for anything a designer would recognize as a document. You can iterate on a template in Chrome DevTools, use Flexbox and Grid, and hand it to a frontend engineer without a translation layer.

Native generation wins when the document is essentially data on a canvas — badges, labels, generated tickets, forms overlaid on existing PDFs. Layout engines get in the way there.

Performance

Native PDF libraries are fast. Generating a simple invoice with PDFKit takes a few milliseconds and uses very little memory.

Puppeteer is slower. Spinning up a browser, loading a page, and rendering to PDF typically takes 500ms–2s per document. You can amortize the browser startup with a pool, but you're still paying hundreds of milliseconds per render.

If you're generating one document per user request, either is fine. If you're batch-generating 100,000 statements overnight, the difference matters.

Operational cost

Running Chromium in production is not free. You need:

bash
apt-get install -y \ libnss3 libatk1.0-0 libatk-bridge2.0-0 libcups2 \ libdrm2 libxkbcommon0 libxcomp1 libgbm1 libpango-1.0-0

Plus enough memory (each Chromium instance eats 100–300 MB), plus a strategy for zombie processes, plus keeping Puppeteer and Chromium versions aligned. On serverless platforms, you need special layers like @sparticuz/chromium and cold starts get painful.

Native PDF libraries have none of this. Deploy the binary, done.

Fidelity and quirks

Puppeteer inherits every quirk of print CSS: page breaks land in weird places, backgrounds don't print without -webkit-print-color-adjust, headers and footers need a separate template. You'll spend real time debugging these.

Native generation has no CSS quirks because there's no CSS. Instead, you have layout quirks — text baselines, kerning, wrapping — that you handle yourself.

PDF-specific features

If you need form fields, digital signatures, PDF/A compliance, tagged PDFs for accessibility, or precise metadata, native libraries usually give you direct access. HTML-to-PDF pipelines tend to require post-processing with something like pdf-lib.

When to pick which

Pick native PDF generation when:

  • The document is simple and stable (labels, tickets, receipts).
  • You need form fields, signatures, or PDF/A output.
  • Throughput matters more than visual polish.
  • You can't run a browser in your target environment.

Pick HTML-to-PDF when:

  • The document has real design (invoices, reports, contracts, offers).
  • Templates change often and non-engineers touch them.
  • You want to reuse existing web components or design tokens.
  • You need multi-page layouts with headers, footers, and page numbers.

The third option: a rendering API

If HTML-to-PDF is the right choice but running Chromium isn't appealing, offloading it to a service removes the operational burden. That's what we built Kamy for — you POST a template and data, we run the browser, you get back a PDF:

ts
import