HTML to PDF in Node.js — the practical 2026 guide cover illustration

HTML to PDF in Node.js — the practical 2026 guide

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 APIfetch + 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):

ScenarioTime
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.

Get API key → Read docs

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/chromium for 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

Frequently asked questions

What is the best way to convert HTML to PDF in Node.js?

For production, either Puppeteer (if you're self-hosting) or a managed HTML-to-PDF API (if you're not). Both produce byte-identical Chromium output. Skip older libraries like html-pdf and phantom-html-to-pdf — they're built on dead engines (PhantomJS, wkhtmltopdf).

Can I use Puppeteer in AWS Lambda for PDF generation?

Yes, with @sparticuz/chromium — a Lambda-sized Chromium build (~55MB compressed). Cold starts are 2-4 seconds; warm renders ~500ms-1s. Works well for low-to-moderate volume serverless PDF generation.

Does Puppeteer work in a Docker container?

Yes, but you need --no-sandbox and some shared-memory flags. The official `puppeteer/puppeteer` Docker image handles this out of the box. For production, use that as a base or follow its Dockerfile.

What's the fastest way to render HTML to PDF in Node.js?

Launching Chromium costs 500-1500ms per cold process. To minimise: keep a browser pool (one long-lived browser, new pages per request) or use a managed API that already runs a warm pool.

How do I wait for React / Vue / Angular to finish rendering before capturing the PDF?

page.setContent() with waitUntil: 'networkidle0', or page.waitForSelector('.ready-marker'), or page.waitForFunction(() => window.__app_ready === true). Pick the tightest signal your app exposes.