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

A practical comparison of native PDF generation and HTML-to-PDF rendering, with code examples and guidance on which approach fits your use case.

pdfhtml-to-pdfnode.jsdocument-generationbackend

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

When you need to produce PDFs from a backend service, you'll quickly hit a fork in the road: do you build the document with a PDF library that draws directly to the page, or do you render HTML and convert it to PDF? Both work. Both have tradeoffs. And picking the wrong one will cost you weeks.

This post compares the two approaches with real code, real failure modes, and a recommendation for when to use each.

What "native PDF generation" actually means

Native PDF generation means using a library like pdf-lib, PDFKit, or ReportLab to construct a PDF programmatically. You position text, draw shapes, embed fonts, and manage pages yourself. There's no browser involved.

A minimal example with pdf-lib:

ts
import { PDFDocument, StandardFonts, rgb } from "pdf-lib"; import { writeFile } from "node:fs/promises"; const pdf = await PDFDocument.create(); const page = pdf.addPage([595, 842]); // A4 const font = await pdf.embedFont(StandardFonts.Helvetica); page.drawText("Invoice INV-001", { x: 50, y: 780, size: 18, font, color: rgb(0, 0, 0), }); const bytes = await pdf.save(); await writeFile("invoice.pdf", bytes);

This is fast, deterministic, and produces tiny output files. It's also tedious. You're doing layout by hand — calculating y offsets, wrapping text, dealing with page breaks, and writing helper functions for anything resembling a table.

What "HTML-to-PDF" actually means

HTML-to-PDF means you render an HTML document (usually with a real browser engine like Chromium) and ask it to print to PDF. The most common tool in Node.js land is 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;"> <h1>Invoice INV-001</h1> <p>Amount due: $1,240.00</p> </body> </html> `); const buffer = await page.pdf({ format: "A4", printBackground: true }); await browser.close();

You get full CSS, web fonts, flexbox, grid, SVG, and a templating story you already know. The cost is operational: you're now running a headless browser in production.

The honest tradeoffs

Layout complexity

Native PDF libraries are great for fixed, simple layouts: tickets, labels, certificates, single-page receipts. Once you need multi-column flow, dynamic tables that paginate cleanly, or anything that should "just reflow," you're going to write a lot of layout code.

HTML-to-PDF handles this trivially. Browsers have been solving layout for 25 years. CSS @page, page-break-inside: avoid, and running headers via position: fixed give you most of what you need.

Fidelity and design iteration

If a designer hands you a Figma file, you can match it in HTML in an afternoon. Matching it with pdf-lib will take days, and every revision is another day. Designers can also preview HTML in a browser without involving you.

Native generation wins on pixel-perfect, print-spec output where you control every millimeter — think compliance documents with strict positioning requirements.

Performance and resource usage

Native PDF generation is cheap. A typical invoice might take 20–50ms and a few megabytes of memory. You can run it inside any worker or Lambda without thinking.

Headless Chromium is the opposite. Cold start can be 1–2 seconds. Each render uses 100–300MB of RAM. You'll want a pool of warm browser instances, or you'll watch latency tank under load.

ts
// Naive: launch a browser per request. Don't do this. const browser = await puppeteer.launch(); // ...render await browser.close();
ts
// Better: reuse a browser, create pages per request. const browser = await puppeteer.launch(); // On each request: const page = await browser.newPage(); await page.setContent(html); const pdf = await page.pdf({ format: "A4" }); await page.close();

Operational burden

Running Chromium in production is its own job. You'll deal with missing system fonts, sandboxing flags, container size bloat (a Chromium image is ~300MB), zombie processes, and the occasional out-of-memory crash. Cloud functions often need custom layers or container images to support it.

This is why managed PDF APIs exist. A service like Kamy renders HTML templates to PDF without you operating the browser fleet:

ts
import { kamy } from "@kamydev/sdk"; const pdf = await kamy.documents.render({ template: "invoice", data: { invoiceNumber: "INV-001", amount: 1240.0, customer: { name: "Acme Corp" }, }, }); console.log(pdf.url); // signed URL, valid for 1 hour

You keep the HTML/CSS authoring model and skip the Chromium operations problem.

Determinism

Native PDF output is byte-stable: same input, same output, forever. This matters for audit trails, hashing, and digital signatures.

Browser-rendered PDFs are not byte-stable across Chromium versions. Fonts, kerning, and rasterization can shift slightly. If you sign the PDF hash, a Chromium upgrade can invalidate your verification flow. Render once and store the artifact.

A simple decision guide

Use native PDF generation when:

  • The layout is simple and fixed (