· Tutorials · By Simon Chiu

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 load event. The script then sets data-charts-ready on <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. pdfinfo reports Tagged: yes. Playwright's default is untagged; with tagged: true its 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-size but no font-family, and pdffonts listed Times-Roman in the output: templates do not inherit the page's CSS. Add font-family to the template's inline style.
  • headerTemplate: '<span></span>' stops Chrome from printing its default header (date and title) when you only want a footer.
  • tagged controls 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 finally block closes Chrome even when a render throws. A missed close() 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() threw PDF generation is only supported for Headless Chromium.
  • The locator waits for an attribute. A flag on <body> is not a visible element, so state: 'attached' is the right wait.
  • emulateMedia({ media: 'print' }) applies @media print rules. 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 finally block.
  • 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 on tagged deliberately. 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