How to convert HTML to PDF in Node.js (2026 guide)
Generating a PDF from HTML is the most practical way to produce invoices, reports and receipts in a Node.js app: you already know HTML and CSS, and a browser engine handles layout, fonts and pagination for you. This guide covers the three approaches that work in 2026, with code you can paste.
Option 1: Puppeteer
Puppeteer drives headless Chrome. It gives the most faithful output, because it's literally Chrome's print engine.
npm install puppeteer
import puppeteer from "puppeteer";
const browser = await puppeteer.launch();
const page = await browser.newPage();
await page.setContent("<h1>Invoice #1042</h1><p>Total: $120.00</p>", { waitUntil: "load" });
const pdf = await page.pdf({ format: "A4", printBackground: true, margin: { top: "20mm", bottom: "20mm" } });
await browser.close();
// pdf is a Uint8Array; write it to disk or send it in a response
To render an existing page instead, replace setContent with await page.goto(url, { waitUntil: "networkidle2" }).
Option 2: Playwright
Playwright's API is almost identical. Only Chromium supports page.pdf().
import { chromium } from "playwright";
const browser = await chromium.launch();
const page = await browser.newPage();
await page.setContent(html);
const pdf = await page.pdf({ format: "Letter", printBackground: true });
await browser.close();
Production pitfalls with self-hosted Chrome
Both libraries work fine on a laptop. In production, these are the problems teams usually hit:
- Memory. Each Chrome instance uses 100–300 MB, and heavy pages use more. Launching one browser per request will take down a small container. Reuse one browser, open a fresh context per job, and cap concurrency.
- Zombie processes. If a render throws before
browser.close(), Chrome keeps running. Always close in afinallyblock and set timeouts. - Missing system libraries and fonts. Slim Docker images lack the shared libraries Chrome needs (
libnss3,libgbm1and others) and have almost no fonts, so non-Latin text and emoji show up as boxes. Installfonts-notoandfonts-noto-color-emoji. - Serverless limits. Chrome is larger than many function size limits, and cold starts add seconds. Lambda-specific Chromium builds exist but need maintenance on every Chrome update.
- Security. If users can influence the HTML or URL, a headless browser can request internal addresses such as
http://169.254.169.254(cloud metadata) orlocalhost. That's a server-side request forgery (SSRF) hole. You need network-level filtering, not just URL checks, because a page can redirect or load subresources.
Option 3: a hosted HTML to PDF API
If you'd rather not run browsers at all, send the HTML to an API and get the PDF back. With PDFLoom, that's one request:
const res = await fetch("https://pdfloom.dev/v1/pdf", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.PDFLOOM_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ html, format: "A4", margin: "20mm" }),
});
if (!res.ok) throw new Error((await res.json()).error);
const pdf = Buffer.from(await res.arrayBuffer());
Rendering still uses real Chrome, so the output matches Puppeteer, but memory, fonts, timeouts and SSRF protection are handled for you. The free plan includes 100 PDFs a month. Get an API key.
Which should you pick?
- Low volume, and you already run Chrome somewhere: Puppeteer.
- You use Playwright for tests already: Playwright.
- Serverless, small containers, or you just want it to work: a hosted API.
CSS tips for good-looking PDFs
@page { size: A4; margin: 16mm; }
h2 { break-after: avoid; }
tr, .card { break-inside: avoid; }
.new-page { break-before: page; }
body { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
See also: adding page numbers, headers and footers.