There are four ways to turn HTML into a PDF from Node.js in 2026. This post walks through each with working code, states the tradeoffs plainly, and recommends which to pick for which scenario.
If you’re short on time: Puppeteer for self-host, a managed HTML-to-PDF API for everything else. The rest of this post justifies that conclusion.
TL;DR
- Puppeteer — Google-official, bundles Chromium, best for self-hosted PDF rendering. 4-6 lines to a working PDF.
- Playwright — Microsoft, multi-browser. PDF output is Chromium-only and byte-identical to Puppeteer. Pick for consistency with existing Playwright investment.
- Managed API —
fetch+ JSON. No binaries, no Docker complexity, no memory management. ~40 lines of Node. - html-pdf / phantom / older libs — skip. Dead engines, abandoned packages.
Option 1: Puppeteer
The shortest path from HTML to PDF in Node. Puppeteer is Google’s library; it bundles a specific Chromium version.
Install
npm i puppeteer
Puppeteer downloads ~170MB of Chromium during install. Set PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=1 and PUPPETEER_EXECUTABLE_PATH if you provide Chromium yourself (Docker, system package, custom image).
The minimum viable example
import puppeteer from 'puppeteer';
async function htmlToPdf(html: string): Promise<Buffer> {
const browser = await puppeteer.launch();
try {
const page = await browser.newPage();
await page.setContent(html, { waitUntil: 'networkidle0' });
return await page.pdf({
format: 'A4',
margin: { top: '20mm', bottom: '20mm', left: '15mm', right: '15mm' },
printBackground: true,
preferCSSPageSize: true,
});
} finally {
await browser.close();
}
}
const pdf = await htmlToPdf('<h1>Invoice #2041</h1><p>Total $1,284</p>');
await Bun.write('out.pdf', pdf); // or fs.writeFile
That’s a working HTML-to-PDF pipeline. Four lines of substance.
Options worth knowing
await page.pdf({
format: 'A4', // or 'Letter', 'Legal', etc.
landscape: false,
margin: { top: '20mm', bottom: '20mm', left: '15mm', right: '15mm' },
printBackground: true, // honour background colours
preferCSSPageSize: true, // let @page rule win over format
displayHeaderFooter: false, // or true + headerTemplate/footerTemplate
headerTemplate: '<div style="font-size:9px;margin:0 15mm">Page <span class="pageNumber"></span> of <span class="totalPages"></span></div>',
footerTemplate: '<div></div>',
scale: 1.0, // 0.1-2.0
timeout: 30000,
});
preferCSSPageSize: true is the single most useful flag — it makes Chromium honour your CSS @page rules. Without it, your options.format overrides CSS and you end up debugging why the page size doesn’t match the design.
Browser pool (production)
Launching Chromium per request costs 500-1500ms. For any real throughput, reuse a browser:
import puppeteer, { Browser } from 'puppeteer';
let browserPromise: Promise<Browser> | null = null;
let requestCount = 0;
const RECYCLE_AFTER = 1000;
async function getBrowser(): Promise<Browser> {
if (!browserPromise || requestCount >= RECYCLE_AFTER) {
if (browserPromise) {
browserPromise.then((b) => b.close());
browserPromise = null;
}
browserPromise = puppeteer.launch({
args: ['--no-sandbox', '--disable-dev-shm-usage'],
});
requestCount = 0;
}
requestCount++;
return browserPromise;
}
export async function htmlToPdf(html: string): Promise<Buffer> {
const browser = await getBrowser();
const page = await browser.newPage();
try {
await page.setContent(html, { waitUntil: 'networkidle0' });
return await page.pdf({ format: 'A4', printBackground: true });
} finally {
await page.close();
}
}
Fresh page per request, recycled browser every 1000 requests (to reclaim leaked memory). For higher throughput, run several of these pools across worker processes — cluster module or a simple process manager.
Puppeteer in Docker
The official image:
FROM node:20-slim
# Install Chromium + fonts
RUN apt-get update && apt-get install -y \
chromium \
fonts-liberation fonts-noto fonts-noto-color-emoji \
&& rm -rf /var/lib/apt/lists/*
ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=1
ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
CMD ["node", "server.js"]
Run with extra shared memory to avoid Chromium crashes:
docker run --shm-size=1gb my-pdf-service
Or pass --disable-dev-shm-usage to Chromium (args shown above).
Puppeteer in AWS Lambda
Use @sparticuz/chromium — a Lambda-optimised Chromium build:
import puppeteer from 'puppeteer-core';
import chromium from '@sparticuz/chromium';
export const handler = async (event: { html: string }) => {
const browser = await puppeteer.launch({
args: chromium.args,
executablePath: await chromium.executablePath(),
headless: true,
});
const page = await browser.newPage();
await page.setContent(event.html, { waitUntil: 'networkidle0' });
const pdf = await page.pdf({ format: 'A4' });
await browser.close();
return { statusCode: 200, body: pdf.toString('base64'), isBase64Encoded: true };
};
Lambda function needs 1024MB+ memory (Chromium is RAM-hungry). Cold start: 2-4 seconds; warm: 500ms-1s.
Option 2: Playwright
Microsoft’s multi-browser automation library. For PDF generation, it drives the same Chromium as Puppeteer and produces identical output.
npm i playwright
npx playwright install chromium
import { chromium } from 'playwright';
async function htmlToPdf(html: string): Promise<Buffer> {
const browser = await chromium.launch();
try {
const page = await browser.newPage();
await page.setContent(html, { waitUntil: 'networkidle' });
return await page.pdf({
format: 'A4',
margin: { top: '20mm', bottom: '20mm', left: '15mm', right: '15mm' },
printBackground: true,
});
} finally {
await browser.close();
}
}
Virtually identical to Puppeteer.
Pick Playwright over Puppeteer if: you already have Playwright installed for browser testing and want one tool. Otherwise, the Puppeteer ecosystem is slightly richer for PDF-specific workflows.
Firefox / WebKit note: Playwright can open pages in Firefox and WebKit, but page.pdf() throws on both — it’s Chromium-only. See our architecture post for why.
Option 3: Managed API
If you don’t want to operate Chromium, a managed HTML-to-PDF API like 21pdf is a 40-line integration:
const QPDF_BASE = 'https://login.21pdf.com/v1';
const QPDF_KEY = process.env.QPDF_KEY!;
async function htmlToPdf(html: string): Promise<Buffer> {
// 1. Submit the render job.
const submit = await fetch(`${QPDF_BASE}/convert`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${QPDF_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
html,
options: {
page_size: 'A4',
margin_top: 20, margin_bottom: 20,
margin_left: 15, margin_right: 15,
wait_for_network_idle: true,
},
}),
});
if (!submit.ok) throw new Error(`submit failed: ${submit.status}`);
const { job_id } = await submit.json() as { job_id: string };
// 2. Poll until complete.
for (let i = 0; i < 60; i++) {
await new Promise(r => setTimeout(r, 500));
const st = await fetch(`${QPDF_BASE}/jobs/${job_id}`, {
headers: { 'Authorization': `Bearer ${QPDF_KEY}` },
}).then(r => r.json()) as { status: string; message?: string };
if (st.status === 'succeeded') break;
if (st.status === 'failed') throw new Error(st.message);
}
// 3. Download the PDF bytes.
const pdf = await fetch(`${QPDF_BASE}/jobs/${job_id}/download`, {
headers: { 'Authorization': `Bearer ${QPDF_KEY}` },
});
if (!pdf.ok) throw new Error(`download failed: ${pdf.status}`);
return Buffer.from(await pdf.arrayBuffer());
}
Works in Node 18+, Bun, Deno, Cloudflare Workers, any runtime with fetch. No binary dependency, no Chromium memory to manage, no Dockerfile tweaks.
Tradeoff: you pay the vendor (21pdf’s free tier is 100 PDFs/month, paid plans from $9). In return you’re not operating Chromium yourself.
The library-vs-API post goes deeper on when this tradeoff is worth it. TL;DR: below ~100k PDFs/day, a managed API is cheaper all-in than the engineering cost of self-hosting Puppeteer.
Option 4: Older libraries
You’ll find a lot of older packages with “html” and “pdf” in the name. Short version: avoid most of them.
- html-pdf — built on PhantomJS, which was archived in 2018. The package still has ~80k weekly npm downloads because legacy integrations haven’t migrated. Don’t start new work here.
- phantom-html-to-pdf — same, PhantomJS.
- wkhtmltopdf (npm wrapper) — wkhtmltopdf upstream went into deprecation in 2023. Weak modern CSS support.
- html-pdf-node — A thin Puppeteer wrapper. If you’re already using Puppeteer directly, you don’t need it.
- pdf-lib / pdfkit — not HTML-to-PDF libraries. They let you construct PDFs programmatically (draw text, add images). Different use case.
If you’re migrating from one of these, Puppeteer is the drop-in replacement for Chromium-based needs; a managed API is the drop-in replacement if you don’t want to run Chromium.
Real-world patterns
A few patterns I’ve seen repeatedly in production Node PDF pipelines.
Wait for web fonts before capturing
Web fonts load asynchronously. Without waiting, Chromium captures with fallback fonts:
await page.setContent(html, { waitUntil: 'networkidle0' });
await page.evaluate(() => document.fonts.ready);
const pdf = await page.pdf({ format: 'A4' });
document.fonts.ready is a Promise that resolves when all @font-face declarations have loaded. Chain it after networkidle0 for belt-and-braces.
Render a React component to PDF
Server-side render your React component to an HTML string, then pass it to the PDF pipeline:
import { renderToStaticMarkup } from 'react-dom/server';
import { Invoice } from './components/Invoice';
const html = `<!doctype html>
<html>
<head><meta charset="utf-8" /><style>${invoiceCss}</style></head>
<body>${renderToStaticMarkup(<Invoice data={invoice} />)}</body>
</html>`;
const pdf = await htmlToPdf(html);
This works with any component you can server-render (React, Vue, Svelte, Solid). The PDF renderer doesn’t care — it just sees HTML.
Batch job pattern
If you’re generating many PDFs in a batch (end-of-month invoices, daily reports), don’t fire them all in parallel:
import pLimit from 'p-limit';
const limit = pLimit(3); // 3 concurrent Chromium pages
const pdfs = await Promise.all(
invoices.map((inv) => limit(() => htmlToPdf(renderInvoice(inv)))),
);
3-5 concurrent renders per Chromium process is a safe cap; beyond that you risk OOM or thrashing.
Returning PDF from Express / Fastify / Hono
app.post('/invoices/:id/pdf', async (req, res) => {
const invoice = await loadInvoice(req.params.id);
const html = renderInvoiceHtml(invoice);
const pdf = await htmlToPdf(html);
res.setHeader('Content-Type', 'application/pdf');
res.setHeader('Content-Disposition', `inline; filename="invoice-${invoice.number}.pdf"`);
res.send(pdf);
});
inline makes browsers display the PDF; attachment forces download.
Email as attachment
import { Resend } from 'resend';
const resend = new Resend(process.env.RESEND_KEY);
await resend.emails.send({
from: '[email protected]',
to: customer.email,
subject: `Invoice ${invoice.number}`,
html: emailBody,
attachments: [{
filename: `${invoice.number}.pdf`,
content: pdf.toString('base64'),
}],
});
Performance reference
Rough numbers on a modern laptop (M2 Pro, warmed up):
| Scenario | Time |
|---|---|
| Puppeteer cold start (new browser) | 800-1500ms |
| Puppeteer warm (new page, existing browser) | 50-150ms |
| Simple HTML → PDF (no JS, no fonts) | 100-200ms |
| Complex HTML (fonts, JS, charts) | 500-2000ms |
| Heavy page (20+ images, web fonts) | 1500-4000ms |
| 21pdf API round-trip (warm) | 300-800ms |
| 21pdf API round-trip (cold) | 1200-2500ms |
Chromium-based services converge on similar numbers because the engine is the same. Differences come from pool warmth, network latency, font-fetch behaviour.
Skip the Chromium management
21pdf is a managed HTML-to-PDF API. 100 PDFs/month free, $9+/month paid. Same Chromium engine Puppeteer uses — without the Dockerfile.
Which option should you pick?
- Building on a PaaS (Vercel, Netlify, Render)? Managed API. Their build environments don’t handle Chromium well.
- AWS Lambda / Cloudflare Workers? Managed API for Workers (no Chromium possible there);
@sparticuz/chromiumfor Lambda if you really want to self-host. - Docker on a VM you control? Puppeteer is fine.
- Kubernetes with proper ops? Puppeteer or Gotenberg (both work).
- High volume (>100k PDFs/day)? Self-host Puppeteer with the browser pool pattern above, or Gotenberg.
- Low-to-moderate volume? Managed API. Your engineering time is worth more than the bill.
Whatever you pick, the general HTML-to-PDF API guide covers the concerns orthogonal to language choice — @page support, SSRF, wait conditions, async models. Worth reading before committing.
— 21pdf Engineering