HTML to PDF in Node.js: Puppeteer, Playwright, PDFKit
HTML to PDF in Node.js with Puppeteer, Playwright, PDFKit or an API, tested on an invoice and a chart report: times, file sizes, page numbers and pitfalls.
To convert HTML to PDF in Node.js, load the HTML into headless Chrome and call page.pdf(). With Playwright, the shortest version is:
import { chromium } from 'playwright'; const browser = await chromium.launch(); const page = await browser.newPage(); await page.setContent('<h1>Invoice INV-2048</h1>', { waitUntil: 'load' }); await page.pdf({ path: 'invoice.pdf', format: 'A4', printBackground: true }); await browser.close();
That works for a heading. A real invoice also needs page numbers, a table that breaks cleanly, and a wait for anything drawn by JavaScript. We ran Puppeteer, Playwright and PDFKit on the same two documents, an invoice and a chart report, and timed them. The results and the code are below, followed by the same job as one API call. For the Python libraries, read the Python version of this test; for what an API does with the PDF options, see the HTML to PDF API page.
Tested on 2026-10-18 on macOS 27 (Apple M1 Pro, 32 GB), Node 22.18.0:
[email protected](Chrome 155.0.8059.79),[email protected](Chromium 156.0.8078.4, headless shell),[email protected]. Cold is the median of 3 launches, warm the median of 5 renders on a running browser. The machine ran other jobs at the same time, so read the numbers as relative. The RenderScreenshot example was not run against the live API for this post.
The two test documents
- Invoice: a header with flexbox and a small CSS grid, a 48-row table, a totals block, and colors from CSS custom properties. It prints on two A4 pages.
- Report: three KPI cards in a CSS grid and two bar charts that a script draws 600 ms after the
loadevent. The script then setsdata-charts-readyon<body>. This copies what a chart library does after it fetches data.
Both use A4 paper, 16 mm top margin, 22 mm bottom margin and a footer that reads "Page 1 of 2".
Results
| Engine | Invoice cold | Invoice warm | Invoice size | Report warm | Page numbers | Charts |
|---|---|---|---|---|---|---|
| Puppeteer (full Chrome, defaults) | 1,037 ms | 182 ms | 130,554 B | 768 ms | yes | yes, with a wait |
Puppeteer, tagged: false |
49,083 B | yes | yes, with a wait | |||
| Playwright (headless shell) | 269 ms | 100 ms | 48,935 B | 881 ms | yes | yes, with a wait |
| PDFKit (drawn in code, no HTML) | 151 ms per process | 4,039 B | not possible | yes, stamped after layout | would need a chart drawn by hand |
What the table shows:
- The report times are mostly the wait. The charts appear 600 ms after load, so every engine spends that time waiting. A real chart library behaves the same way.
- Puppeteer's cold start was the slowest because it launches full Chrome in the new headless mode. With
headless: 'shell', the cold median for the invoice fell from 1,456 ms to 416 ms in a separate run. Our first cold run after installing Puppeteer took 5.8 seconds; macOS checks a new binary the first time it runs. - Puppeteer's file is 2.7 times larger by default because it writes a tagged PDF with a structure tree for screen readers.
pdfinforeportsTagged: yes. Playwright's default is untagged; withtagged: trueits invoice grew to 130,435 bytes. - PDFKit is tiny and fast, but it is not HTML to PDF. You place every line of text yourself. It suits receipts and labels with a fixed layout, not your existing templates.
Puppeteer
npm install [email protected]
// puppeteer-pdf.mjs: node puppeteer-pdf.mjs invoice.html invoice.pdf import { readFile, writeFile } from 'node:fs/promises'; import puppeteer from 'puppeteer'; const [input, output] = process.argv.slice(2); const html = await readFile(input, 'utf8'); const browser = await puppeteer.launch(); try { const page = await browser.newPage(); await page.setContent(html, { waitUntil: 'load' }); // Report template: charts draw after load. Wait for the flag, not a fixed delay. if (html.includes('chartsReady')) { await page.waitForSelector('body[data-charts-ready="true"]', { timeout: 10_000 }); } await page.evaluate(() => document.fonts.ready); const pdf = await page.pdf({ format: 'A4', printBackground: true, margin: { top: '16mm', right: '14mm', bottom: '22mm', left: '14mm' }, displayHeaderFooter: true, headerTemplate: '<span></span>', footerTemplate: '<div style="font-size:9px;width:100%;text-align:center;color:#64748b">' + 'Page <span class="pageNumber"></span> of <span class="totalPages"></span></div>', }); await writeFile(output, pdf); console.log(`${output}: ${pdf.length} bytes`); } finally { await browser.close(); }
Points that matter:
printBackground: true. Chrome leaves out background colors and images when printing unless you ask for them. Table stripes and colored header bands are backgrounds.await page.evaluate(() => document.fonts.ready)waits for web fonts. Without it, a font that loads late prints in the fallback face.- The footer prints in Times. This footer has a
font-sizebut nofont-family, andpdffontslisted Times-Roman in the output: templates do not inherit the page's CSS. Addfont-familyto the template's inline style. headerTemplate: '<span></span>'stops Chrome from printing its default header (date and title) when you only want a footer.taggedcontrols the accessibility structure discussed above. Leave it on for documents that people with screen readers receive; switch it off for internal files where size matters.- The
finallyblock closes Chrome even when a render throws. A missedclose()leaves a Chrome process behind on every error; our post on Puppeteer timeouts and memory leaks covers what that does to a server.
Playwright
npm install [email protected] npx playwright install chromium
// playwright-pdf.mjs: node playwright-pdf.mjs report.html report.pdf import { readFile } from 'node:fs/promises'; import { chromium } from 'playwright'; const [input, output] = process.argv.slice(2); const html = await readFile(input, 'utf8'); const browser = await chromium.launch(); try { const page = await browser.newPage(); await page.setContent(html, { waitUntil: 'load' }); if (html.includes('chartsReady')) { await page.locator('body[data-charts-ready="true"]').waitFor({ state: 'attached', timeout: 10_000 }); } await page.emulateMedia({ media: 'print' }); await page.pdf({ path: output, format: 'A4', printBackground: true, margin: { top: '16mm', right: '14mm', bottom: '22mm', left: '14mm' }, displayHeaderFooter: true, headerTemplate: '<span></span>', footerTemplate: '<div style="font:9px Helvetica,Arial,sans-serif;width:100%;text-align:center;color:#64748b">' + 'Page <span class="pageNumber"></span> of <span class="totalPages"></span></div>', }); console.log(`${output} written`); } finally { await browser.close(); }
Differences from Puppeteer:
- Chromium only. With Firefox,
page.pdf()threwPDF generation is only supported for Headless Chromium. - The locator waits for an attribute. A flag on
<body>is not a visible element, sostate: 'attached'is the right wait. emulateMedia({ media: 'print' })applies@media printrules.page.pdf()prints with print styles anyway; the call matters when you also take a screenshot of the same page.
Why waiting matters: when we printed the report at the load event with no wait, the PDF had the KPI cards and two empty chart frames. pdftotext found no chart labels at all.
PDFKit: drawing the invoice in code
npm install [email protected]
// pdfkit-invoice.mjs: the invoice drawn with PDFKit. Every line is placed by hand. import { createWriteStream } from 'node:fs'; import { finished } from 'node:stream/promises'; import PDFDocument from 'pdfkit'; const items = Array.from({ length: 48 }, (_, k) => { const i = k + 1; return { n: String(i).padStart(2, '0'), desc: `Render credits block ${i}`, qty: (i % 5) + 1, price: 12 + ((i * 7) % 90) }; }); const doc = new PDFDocument({ size: 'A4', margins: { top: 45, bottom: 62, left: 40, right: 40 }, bufferPages: true }); const out = createWriteStream(process.argv[2] ?? 'invoice-pdfkit.pdf'); doc.pipe(out); const cols = [40, 70, 330, 380, 450]; const header = () => { doc.font('Helvetica-Bold').fontSize(8).fillColor('#64748b'); ['#', 'DESCRIPTION', 'QTY', 'UNIT', 'AMOUNT'].forEach((t, c) => doc.text(t, cols[c], doc.y, { lineBreak: false })); doc.moveDown(1.2); }; doc.font('Helvetica-Bold').fontSize(20).fillColor('#2563eb').text('Acme Rendering Co.'); doc.font('Helvetica').fontSize(10).fillColor('#0f172a').text('Invoice INV-2048 · Issued 2026-10-18 · Due 2026-11-17'); doc.moveDown(); header(); let total = 0; doc.font('Helvetica').fontSize(10).fillColor('#0f172a'); for (const it of items) { if (doc.y > doc.page.height - doc.page.margins.bottom - 20) { doc.addPage(); header(); doc.font('Helvetica').fontSize(10).fillColor('#0f172a'); } const y = doc.y; const amount = it.qty * it.price; total += amount; doc.text(it.n, cols[0], y, { lineBreak: false }); doc.text(it.desc, cols[1], y, { lineBreak: false }); doc.text(String(it.qty), cols[2], y, { width: 40, align: 'right', lineBreak: false }); doc.text(`$${it.price}.00`, cols[3], y, { width: 60, align: 'right', lineBreak: false }); doc.text(`$${amount}.00`, cols[4], y, { width: 100, align: 'right', lineBreak: false }); doc.moveDown(1.1); } doc.moveDown().font('Helvetica-Bold').fontSize(13).text(`Amount due $${total}.00`, 300, doc.y, { width: 250, align: 'right' }); // "Page N of M" needs the final page count, so stamp it after layout. const range = doc.bufferedPageRange(); for (let p = range.start; p < range.start + range.count; p++) { doc.switchToPage(p); const bottom = doc.page.margins.bottom; doc.page.margins.bottom = 0; // text inside the margin would otherwise start a new page doc.font('Helvetica').fontSize(8).fillColor('#64748b') .text(`Page ${p + 1} of ${range.count}`, 0, doc.page.height - 35, { align: 'center', width: doc.page.width }); doc.page.margins.bottom = bottom; } doc.end(); await finished(out);
The trap here is the footer. When we stamped the page numbers without setting the bottom margin to 0 first, PDFKit treated each footer as overflow and added a page: the two-page invoice came out with four pages. The pagination loop and the column positions are also yours to maintain; that is the price of a 4 KB file with no browser.
Running Chrome in production
The browser libraries are easy on a laptop and harder on a server:
- Launch once, open a page per document. For the invoice, a warm render was 2.7 times (Playwright) to 5.7 times (Puppeteer) faster than a cold one. Close each page in a
finallyblock. - Limit concurrency. Each open page holds memory; queue documents rather than opening one page per incoming request.
- Set timeouts on every wait and turn a timeout into an error, not into a PDF with empty charts.
- Install fonts in the container. A slim Linux image has few fonts, so text falls back to whatever is there. Our post on consistent fonts in headless rendering has the packages.
- Prefer the headless shell for printing.
headless: 'shell'in Puppeteer, or Playwright's default, starts faster and prints the same PDF features.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
| Charts are empty boxes | Printed before the chart script drew | Wait for a flag the page sets after drawing (waitForSelector or a locator) |
| Puppeteer PDF much larger than expected | Tagged PDF is the default | tagged: false, if you do not need the accessibility structure |
| Page numbers in a serif font | Templates do not inherit page CSS | Inline font-family and font-size in the template |
| Date and title appear at the top | Chrome's default header | headerTemplate: '<span></span>' |
| Footer overlaps the last lines | Bottom margin too small | At least 20 mm bottom margin with a footer |
| Colors and stripes missing | Backgrounds are off when printing | printBackground: true |
margin option has no effect |
The page's @page rule sets its own margin, and it wins |
Remove the @page margin or set it to the value you want |
page.pdf throws in Playwright |
Firefox or WebKit browser | Use chromium |
| PDFKit output has extra blank pages | Text written inside the bottom margin | Set doc.page.margins.bottom = 0 while stamping footers |
| Text in a fallback font on the server | Font not installed or not loaded | Install fonts; await document.fonts.ready before pdf() |
The same invoice with one API call
If you would rather not run Chrome, send the HTML to an HTML to PDF API. This is the invoice above with the Node SDK:
// npm install renderscreenshot import { readFile, writeFile } from 'node:fs/promises'; import { Client, TakeOptions } from 'renderscreenshot'; const client = new Client(process.env.RS_API_KEY); const html = await readFile('invoice.html', 'utf8'); const pdf = await client.take( TakeOptions.html(html) .format('pdf') .pdfPaperSize('a4') .pdfMargin('16mm') .pdfPrintBackground() .pdfHeader('<span></span>') .pdfFooter('<div style="font:9px Helvetica,Arial,sans-serif;width:100%;text-align:center">Page <span class="pageNumber"></span> of <span class="totalPages"></span></div>') ); await writeFile('invoice.pdf', pdf);
For the report, add .waitForSelector('body[data-charts-ready="true"]'). The API also prints with Chrome, so the CSS rules in this post apply unchanged; options such as fit_one_page and page_ranges are in the PDF parameters. One render is one credit, whatever the page count, and the free plan includes 150 credits a month.
Which one to choose
- Playwright for HTML to PDF in your own process. It started fastest in our test and its defaults give small files.
- Puppeteer if you already use it; switch to
headless: 'shell'and decide ontaggeddeliberately. Our Puppeteer page.pdf() guide tests every print option. - PDFKit for fixed layouts you are happy to draw in code: receipts, labels, tickets.
- An API when you do not want Chrome on your servers. The invoice PDF use case has job code for Rails, Laravel and Node.
Get a free API key and send your own invoice template.
Related in this topic
- HTML to PDF API: Paper, Margins, Page Numbers Turn a URL or your HTML into a PDF with real Chrome: paper sizes, margins, header and footer templates with page numbers, one-page fit and print CSS.
- Best HTML to PDF APIs (2026): 7 Options Compared DocRaptor, PDFShift, Api2Pdf, PDFMonkey, Gotenberg, Browserless and RenderScreenshot compared: engines, prices at three volumes, code and an engine test.
- HTML to PDF in Python: WeasyPrint vs pdfkit vs Playwright HTML to PDF in Python with WeasyPrint, pdfkit (wkhtmltopdf), Playwright or an API, tested on an invoice and a chart report: CSS support, timings, pitfalls.
- Laravel HTML to PDF: dompdf, Browsershot or laravel-pdf Generate PDFs from Blade views in Laravel: dompdf, Browsershot, spatie/laravel-pdf and an API, measured on one invoice, with queued jobs, Storage and tests.
- Rails HTML to PDF: wicked_pdf vs Grover vs Prawn (2026) Generate PDFs in Rails 8: wicked_pdf, Grover, Prawn or an API. Measured speed and size on one invoice, Active Storage code, and when each choice fits.
- URL to PDF in Node.js and Python, Tested on Live Pages Convert a URL to PDF in Node.js and Python: print media, cookie banners that repeat on every page, lazy images that never load, with tested scripts.