How to convert HTML to PDF in Node.js (2026)
Turning HTML into a PDF is one of those tasks that looks like a five-line script and turns into a small infrastructure project. This guide shows the two approaches that actually produce good output in Node.js today, Puppeteer and Playwright, explains what goes wrong when you ship them to production, and then shows how to do the same thing with a single HTTP request to PDFOven.
Why use a headless browser at all?
Older libraries such as html-pdf (built on the long-abandoned PhantomJS) or pure-JavaScript layout engines cannot handle modern CSS. If your invoice uses flexbox, grid, web fonts or CSS variables, you need a real browser engine. In practice that means headless Chromium, driven by Puppeteer or Playwright. Chromium's print pipeline is the same one used when you press Ctrl+P in Chrome, so what you see in DevTools print preview is what you get.
Option 1: Puppeteer
Install it (this downloads a matching Chrome build, a few hundred megabytes on disk):
npm install puppeteer
import puppeteer from "puppeteer";
import { readFile, writeFile } from "node:fs/promises";
const html = await readFile("invoice.html", "utf8");
const browser = await puppeteer.launch();
try {
const page = await browser.newPage();
await page.setContent(html, { waitUntil: "networkidle0" });
const pdf = await page.pdf({
format: "A4",
printBackground: true,
margin: { top: "0.5in", right: "0.5in", bottom: "0.5in", left: "0.5in" },
});
await writeFile("invoice.pdf", pdf);
} finally {
await browser.close();
}
A few details that matter:
waitUntil: "networkidle0"waits for images and web fonts referenced by the HTML to finish loading. Without it you will occasionally get PDFs with fallback fonts or missing logos.printBackground: trueis required for background colors and images. Chromium omits them by default, just like the browser print dialog.- Use
@pageand print CSS (break-inside: avoid,thead { display: table-header-group; }) to control pagination.
Option 2: Playwright
Playwright's API is almost identical and is a good choice if you already use it for end-to-end tests:
import { chromium } from "playwright";
const browser = await chromium.launch();
try {
const page = await browser.newPage();
await page.setContent(html, { waitUntil: "networkidle" });
await page.pdf({ path: "invoice.pdf", format: "A4", printBackground: true });
} finally {
await browser.close();
}
PDF generation only works in Chromium in both libraries; Firefox and WebKit do not support page.pdf().
Headers, footers and page numbers
Chromium can stamp a header and footer on every page. The template is a separate HTML snippet with special classes:
await page.pdf({
format: "A4",
displayHeaderFooter: true,
headerTemplate: "<span></span>",
footerTemplate:
'<div style="font-size:9px;width:100%;text-align:center">' +
'Page <span class="pageNumber"></span> of <span class="totalPages"></span></div>',
margin: { top: "0.6in", bottom: "0.6in" },
});
The footer does not inherit your page CSS, so set the font size explicitly, and leave enough bottom margin or it will be clipped.
What breaks in production
The script above works on your laptop. These are the problems teams usually hit next:
- Memory. Each Chromium instance uses hundreds of megabytes, and every open page adds more. Launching a browser per request is slow and spiky; sharing one browser means you now own a page pool, concurrency limits and crash recovery.
- Zombie processes. If your code throws before
browser.close()or the Node process is killed, orphaned Chromium processes accumulate until the container runs out of memory. - Fonts. Slim Docker images ship with almost no fonts. Text in Chinese, Japanese, Arabic or emoji renders as empty boxes until you install packages such as
fonts-noto,fonts-noto-cjkandfonts-noto-color-emoji. - System libraries. Chromium needs a long list of shared libraries (
libnss3,libatk,libgbmand more). Base image upgrades can break it. - Serverless limits. On AWS Lambda or Vercel the full Chromium binary exceeds bundle size limits, so you need a trimmed build such as
@sparticuz/chromiumand you pay a cold start every time a fresh instance unpacks it. - Security. If any HTML or URL comes from users, a headless browser on your network can be pointed at internal services or
file://paths unless you intercept and block those requests.
None of these are unsolvable, but together they are a service you have to run and keep patched.
The PDFOven way
PDFOven runs the browser fleet for you. You send HTML (or Markdown, a URL, or a template plus data) and get a PDF back:
import { writeFile } from "node:fs/promises";
const res = await fetch("https://pdfoven.com/v1/pdf", {
method: "POST",
headers: {
Authorization: "Bearer " + process.env.PDFOVEN_KEY,
"Content-Type": "application/json",
},
body: JSON.stringify({
html,
options: {
format: "A4",
margin: "0.5in",
print_background: true,
footer_template:
'<div style="font-size:9px;width:100%;text-align:center">' +
'Page <span class="pageNumber"></span> of <span class="totalPages"></span></div>',
},
wait_for_selector: "#ready",
}),
});
if (!res.ok) {
const { error } = await res.json();
throw new Error(error.code + ": " + error.message);
}
await writeFile("invoice.pdf", Buffer.from(await res.arrayBuffer()));
The same request from the command line:
curl -X POST https://pdfoven.com/v1/pdf \
-H "Authorization: Bearer $PDFOVEN_KEY" \
-H "Content-Type: application/json" \
-d '{"html":"<h1>Hello</h1>","options":{"format":"Letter"}}' \
-o hello.pdf
Useful options:
csslets you keep styles separate from the markup.wait_for_selectorwaits for an element your client-side code adds when charts or data have finished rendering.response: "base64"returns JSON (data,content_type,bytes) instead of raw bytes, which is handy for queues and JSON-only transports.POST /v1/imagerenders the same input to PNG (or JPEG with?type=jpeg) for thumbnails and social cards.
Errors always come back as { "error": { "code", "message" } }, and a 429 with code quota_exceeded means you have used your monthly renders. Rendered files are not stored on our side; the bytes go straight back to you.
Which should you choose?
If you render a handful of PDFs from a long-running server you already operate, Puppeteer or Playwright is a perfectly good answer. If you deploy to serverless, need to handle bursts, or would rather not maintain Chromium images and font packages, an API is less work. PDFOven's free plan includes 100 renders per month, and paid plans are $12, $39 and $99 for 2,000, 15,000 and 60,000 renders. You can also try the free HTML to PDF tool in your browser before writing any code.
Skip the browser plumbing
PDFOven runs Chromium for you. Send HTML, Markdown, a URL or JSON to a template and get a PDF back. 100 free renders every month.
Get a free API key