You need to generate a PDF from HTML in Node.js. Maybe it's invoices. Maybe it's reports. Maybe your PM just said "can we add an export button?" and now it's your problem.
Here's the thing: there are at least five different ways to do this, and every Stack Overflow thread recommends a different one. Puppeteer. wkhtmltopdf. jsPDF. pdf-lib. A paid API. They all "work" in the sense that they produce a .pdf file. But the tradeoffs are wildly different in terms of CSS support, memory usage, deployment pain, and long-term maintenance.
This guide covers all five approaches with real, working code. No hand-waving. By the end you'll know exactly which one fits your situation—and which ones to avoid.
Method 1: Puppeteer
Puppeteer launches a headless Chromium browser, loads your HTML, and uses the built-in print-to-PDF functionality. It's the most common approach because it gives you real browser rendering—CSS Grid, Flexbox, web fonts, the works.
import puppeteer from 'puppeteer'
async function htmlToPdf(html) {
const browser = await puppeteer.launch()
const page = await browser.newPage()
await page.setContent(html, {
waitUntil: 'networkidle0'
})
const pdf = await page.pdf({
format: 'A4',
printBackground: true,
margin: { top: '20mm', bottom: '20mm', left: '15mm', right: '15mm' }
})
await browser.close()
return pdf // Buffer
}
This works. And for a weekend project, it's fine. But take it to production and the problems start stacking up.
The downsides
- 400MB Chromium binary. Puppeteer downloads its own Chromium on
npm install. Your Docker image just got huge. On serverless platforms like AWS Lambda, you need@sparticuz/chromiumand a Lambda layer to make it fit at all. - Memory-hungry. Each browser instance consumes 100-300MB of RAM. Under load, you need browser pooling, which means you're now building infrastructure instead of features.
- Cold starts. Launching Chromium takes 1-3 seconds. If you're generating PDFs on-demand in an API handler, that latency is brutal.
- Browser lifecycle management. Crashed tabs, zombie processes, memory leaks. You need health checks, graceful restarts, and timeouts. It's a whole category of ops work.
- CSS drift between Chromium versions. Puppeteer pins a Chromium version, but when you upgrade, subtle rendering changes can break your layouts. There's no changelog for "we moved this margin by 0.5px."
For low-volume, simple-layout use cases, Puppeteer is legitimate. For anything more, you're signing up for a side project inside your side project.
Method 2: wkhtmltopdf
The old guard. wkhtmltopdf uses the Qt WebKit rendering engine to convert HTML to PDF. It's been around since 2008 and still shows up in production systems everywhere.
import wkhtmltopdf from 'wkhtmltopdf'
import { createWriteStream } from 'fs'
const html = '<h1>Invoice #1042</h1><p>Total: $450.00</p>'
wkhtmltopdf(html, {
pageSize: 'A4',
marginTop: '20mm',
marginBottom: '20mm'
}).pipe(createWriteStream('invoice.pdf'))
The downsides
- WebKit, not Chromium. It uses an old Qt WebKit fork—not the modern WebKit in Safari. CSS Grid doesn't work. Flexbox is partially supported. If your HTML was designed for modern browsers, expect layout breakage.
- Unmaintained. The upstream project has been archived. The last release was in 2020. Security patches don't happen anymore.
- System dependency. The
node-wkhtmltopdfnpm package is just a wrapper—you still need the wkhtmltopdf binary installed on your system. That's another apt-get in your Dockerfile. - Known security issues. wkhtmltopdf can read local files via
file://protocol. If you're rendering user-supplied HTML, this is a server-side request forgery (SSRF) vector. You need to sanitize inputs aggressively.
If you're maintaining a legacy system that already uses wkhtmltopdf, it works. But there's no reason to start a new project with it in 2026. Make a plan to migrate.
Method 3: jsPDF
jsPDF generates PDFs entirely in JavaScript—no headless browser, no system binary. It's primarily a client-side library, though it runs in Node.js too. The catch: it doesn't render HTML. You build the PDF manually.
import { jsPDF } from 'jspdf'
const doc = new jsPDF()
// Manual positioning — x, y coordinates in mm
doc.setFontSize(24)
doc.text('Invoice #1042', 20, 30)
doc.setFontSize(12)
doc.text('Customer: Acme Corp', 20, 45)
doc.text('Total: $450.00', 20, 55)
// Draw a line
doc.line(20, 60, 190, 60)
doc.save('invoice.pdf')
See the problem? You're positioning every element with pixel (well, millimeter) coordinates. There's no layout engine. No CSS. No "put this div next to that div."
The downsides
- No HTML rendering. jsPDF doesn't understand HTML or CSS. You manually place text, draw shapes, and add images using coordinates. For anything beyond a simple receipt, this is painful.
- The html2canvas workaround. You can combine jsPDF with
html2canvasto screenshot HTML and embed it as an image—but the result is a rasterized image inside a PDF, not real text. No copy/paste, no search, terrible for accessibility. - Client-side focus. jsPDF works in Node.js but it was designed for the browser. Many features assume a DOM exists.
- Complex layouts are hell. Tables, multi-column layouts, page breaks—all manual. You'll spend more time on layout math than on your actual feature.
jsPDF is great for one thing: letting users generate simple PDFs client-side without any server involvement. For server-side HTML-to-PDF, look elsewhere.
Method 4: pdf-lib
pdf-lib is a pure JavaScript library for creating and modifying PDF documents. Unlike jsPDF, it has a clean, modern API and excellent TypeScript support. It's also genuinely good at what it does—which is not HTML rendering.
import { PDFDocument, StandardFonts, rgb } from 'pdf-lib'
const pdfDoc = await PDFDocument.create()
const font = await pdfDoc.embedFont(StandardFonts.Helvetica)
const page = pdfDoc.addPage([595, 842]) // A4 in points
page.drawText('Invoice #1042', {
x: 50, y: 780,
size: 24,
font,
color: rgb(0, 0, 0)
})
const bytes = await pdfDoc.save()
// bytes is a Uint8Array — write to disk or send as response
The downsides
- No HTML rendering. At all. pdf-lib constructs PDFs from primitives: text, shapes, images, pages. If you have HTML, you need to parse it yourself and manually translate every element into draw calls.
- Manual page layout. You're computing coordinates, handling text wrapping, managing page breaks. It's like building a layout engine from scratch.
- Limited fonts. The standard PDF fonts are available, but embedding custom fonts requires reading the font file and embedding it manually.
Where pdf-lib shines is programmatic PDF manipulation: merging documents, filling form fields, adding watermarks, extracting metadata. If you need to stamp a "PAID" watermark on 10,000 existing PDFs, pdf-lib is brilliant. But for converting an HTML invoice to PDF? Wrong tool.
Method 5: Using a PDF API (PDFBase)
Instead of running a browser on your server, you send HTML to an API and get a PDF back. PDFBase runs Chromium on dedicated infrastructure—warm instances, no cold starts, pinned browser versions—so you don't have to.
import PDFBase from 'pdfbase'
const client = new PDFBase('pk_live_...')
const pdf = await client.pdfs.create({
html: `
<html>
<head>
<style>
body { font-family: 'Inter', sans-serif; padding: 40px; }
.header { display: flex; justify-content: space-between; }
table { width: 100%; border-collapse: collapse; margin-top: 2rem; }
th, td { padding: 12px; text-align: left; border-bottom: 1px solid #eee; }
</style>
</head>
<body>
<div class="header">
<h1>Invoice #1042</h1>
<p>May 20, 2026</p>
</div>
<table>
<tr><th>Item</th><th>Qty</th><th>Price</th></tr>
<tr><td>API Credits</td><td>5,000</td><td>$450.00</td></tr>
</table>
</body>
</html>
`,
format: 'A4',
output: 'url'
})
console.log(pdf.data.url) // signed URL, 24h expiry
console.log(pdf.data.pages) // 1
console.log(pdf.data.bytes) // 24891
That's it. No browser to install, no Docker layers, no browser pool to manage. You send HTML, you get a PDF.
Why this approach works at scale
- No Chromium on your server. PDFBase runs warm Chromium instances on dedicated infrastructure. Your app stays lightweight.
- Full CSS support. It's real Chromium rendering. CSS Grid, Flexbox,
@media print, web fonts, JavaScript—everything works exactly like it does in Chrome. - Sub-200ms response times. No cold starts. Warm browser pools mean your PDF is ready before your user's loading spinner finishes its first rotation.
- Debug mode. Pass
debug: trueand get a screenshot of the rendered page, console logs, and a list of failed resource loads alongside your PDF. No more guessing why the CSS looks wrong. - Pinned Chromium version. The exact Chromium version is documented. No surprise rendering changes after an upgrade. When they do update, there's a changelog.
You can also pass a url instead of raw HTML if you want to render a live page, and use output: 'buffer' to get the raw bytes instead of a signed URL.
For more advanced usage—templates, watermarks, merging, batch processing—check the PDFBase docs. Or try the interactive HTML to PDF tool to see it in action without writing code.
Comparison
Here's how all five methods stack up across the dimensions that actually matter in production.
| Puppeteer | wkhtmltopdf | jsPDF | pdf-lib | PDFBase API | |
|---|---|---|---|---|---|
| CSS Support | Full | Partial (old WebKit) | None | None | Full (Chromium) |
| Runs On | Server | Server | Client + Server | Client + Server | API (any runtime) |
| Setup Complexity | Medium (Chromium) | Medium (system binary) | Low | Low | Trivial (npm install) |
| Maintenance | High (browser ops) | Low (unmaintained) | Low | Low | Zero (managed) |
| Memory Usage | 100-300MB per instance | 50-100MB | Minimal | Minimal | None (remote) |
| Best For | Simple layouts, low volume | Legacy systems | Client-side export | PDF manipulation | Production HTML-to-PDF |
Which Should You Choose?
Skip the analysis paralysis. Here's a decision tree:
Under 100 PDFs/month, simple HTML
Use Puppeteer. It's free, it works, and the infrastructure overhead is manageable at low scale. Just don't underestimate the Docker image size and memory usage.
Complex layouts, production workloads, or you don't want ops burden
Use a PDF API like PDFBase. The math is simple: engineering time spent managing Chromium infrastructure costs more than $0.01 per PDF. You get full CSS support, reliable rendering, and zero browser ops.
Programmatic PDFs (no HTML input)
Use pdf-lib. If you're constructing PDFs from data—filling forms, merging documents, adding watermarks to existing files—pdf-lib has the best API for the job.
Client-side only, no server
Use jsPDF. It runs in the browser with zero backend. Good for simple receipts or export buttons where you need a "download as PDF" feature without server involvement.
Legacy system already using wkhtmltopdf
Keep it running, but plan your migration. wkhtmltopdf is unmaintained with known security issues. It works today, but every month you wait makes the migration harder.
Wrapping Up
Converting HTML to PDF in Node.js isn't hard. Doing it reliably at scale without turning your team into browser infrastructure engineers—that's the actual challenge.
Pick the tool that matches your constraints. For most production applications that need to render real HTML with real CSS, the choice comes down to Puppeteer (self-managed) or an API (managed). Everything else is a niche tool for a niche problem.
If you want to try PDFBase, you can grab 100 free credits without a credit card. The docs cover everything from basic generation to templates, watermarks, batch processing, and the MCP server for AI agents.