ScreenshotNeo

BlogComparisons

HTML-to-PDF Libraries on npm: What to Choose

Compare Puppeteer, Playwright, html-pdf-node, and PDFKit with runnable Node.js code, pagination tips, deployment guidance, and fixes.

By the ScreenshotNeo team1 October 20267 min read

Short answer: use a maintained browser engine for HTML that depends on modern CSS, JavaScript, charts, or web fonts. Start with Puppeteer or Playwright. Choose PDFKit when the document is a fixed, programmatic layout. Choose html-pdf-node when you want a small wrapper around Puppeteer without changing its browser runtime.

This guide compares the npm choices, shows complete Node.js code, explains print pagination and deployment, and lists the failure modes that usually make HTML-to-PDF jobs unreliable.

1. Choose by the document you are rendering

Input and requirement Best starting point Reason
Existing React, Vue, SSR, or static page; charts; web fonts; client-side JavaScript Puppeteer or Playwright A real browser executes layout and scripts.
Simple HTML conversion with familiar Puppeteer options html-pdf-node Convenience wrapper; Chromium behavior and deployment requirements remain.
Invoice, certificate, or report whose coordinates and text are known in advance PDFKit Direct drawing and streaming avoid browser startup.
Strict paged-media features Dedicated paged-media engine after fixture testing Browser PDF controls are useful but do not implement every paged-media feature.
Legacy PhantomJS or wkhtmltopdf integration Plan a migration Older engines can lag current CSS and JavaScript; compare rendered fixtures.

The five checks that matter are HTML/CSS/JavaScript fidelity, pagination controls, deployment footprint, engine maintenance, and whether your team prefers HTML templates or a drawing API.

2. Puppeteer: browser-fidelity HTML to PDF

Puppeteer generates PDFs with print CSS media by default. You can switch to screen media, set paper size and margins, add headers and footers, wait for fonts, and let @page control dimensions.

Install and run

npm install puppeteer
node render-puppeteer.mjs
import puppeteer from 'puppeteer';

const browser = await puppeteer.launch({args: process.env.CI ? ['--no-sandbox', '--disable-setuid-sandbox'] : []});
try {
  const page = await browser.newPage();
  await page.goto('https://example.com/report', {waitUntil: 'networkidle0', timeout: 60000});
  await page.emulateMediaType('screen');
  await page.evaluate(() => document.fonts.ready);
  await page.pdf({path: 'report.pdf', format: 'A4', printBackground: true, preferCSSPageSize: true, displayHeaderFooter: true, headerTemplate: '', footerTemplate: '
/
', margin: {top: '18mm', right: '14mm', bottom: '18mm', left: '14mm'}, scale: 1}); } finally { await browser.close(); }

Render supplied HTML instead of a URL

await page.setContent('<!doctype html><html><head><style>@page { size: A4; margin: 16mm; } body { font-family: Arial, sans-serif; } .keep-together { break-inside: avoid; }</style></head><body><h1>Invoice</h1></body></html>', {waitUntil: 'networkidle0'});
await page.pdf({path: 'invoice.pdf', preferCSSPageSize: true, printBackground: true});

Important Puppeteer options

  • format or width/height sets paper size; preferCSSPageSize honors an explicit @page.
  • margin accepts CSS lengths. Header and footer templates need inline styles and have limited browser context.
  • printBackground preserves color and background images.
  • scale changes layout size; verify that it does not create unexpected page breaks.
  • page.emulateMediaType('screen') keeps screen rules when print CSS is not desired.
  • Use waitUntil, an explicit selector wait, and document.fonts.ready for asynchronous pages.

3. Playwright: the same model with multiple browser engines

Playwright’s page PDF API follows the browser approach and is useful when your test or rendering stack already uses Playwright. Chromium is the usual PDF target; keep the browser version and fonts in your deployment image consistent.

npm install playwright
npx playwright install chromium
import { chromium } from 'playwright';
const browser = await chromium.launch();
try {
  const page = await browser.newPage();
  await page.goto('https://example.com/report', {waitUntil: 'networkidle', timeout: 60000});
  await page.emulateMedia({media: 'print'});
  await page.evaluate(() => document.fonts.ready);
  await page.pdf({path: 'report.pdf', format: 'A4', printBackground: true, preferCSSPageSize: true, margin: {top: '18mm', bottom: '18mm', left: '14mm', right: '14mm'}});
} finally { await browser.close(); }

4. html-pdf-node: less glue, same Chromium dependency

html-pdf-node wraps Puppeteer. It exposes common format, margin, scale, and preferCSSPageSize settings, but it still downloads and runs a compatible Chromium binary.

npm install html-pdf-node
import fs from 'node:fs';
import pdf from 'html-pdf-node';
const file = {content: '<h1>Monthly report</h1><p>Ready to print.</p>'};
const options = {format: 'A4', printBackground: true, preferCSSPageSize: true, margin: {top: '16mm', right: '14mm', bottom: '16mm', left: '14mm'}};
const buffer = await pdf.generatePdf(file, options);
fs.writeFileSync('report.pdf', buffer);

5. PDFKit: choose a drawing API for fixed layouts

PDFKit is a PDF document generation library for Node and the browser. It gives you coordinates, fonts, drawing primitives, and streams; it does not render arbitrary website CSS or execute page JavaScript.

npm install pdfkit
import PDFDocument from 'pdfkit';
import fs from 'node:fs';
const doc = new PDFDocument({size: 'A4', margins: {top: 50, bottom: 50, left: 50, right: 50}});
doc.pipe(fs.createWriteStream('invoice.pdf'));
doc.fontSize(22).text('Invoice', {underline: true});
doc.moveDown().fontSize(11).text('Customer: Ada Lovelace');
doc.text('Amount due: $120.00');
doc.moveDown().text('Thank you for your business.');
doc.end();

Use PDFKit when the source data is the document and you want predictable streams. If the source is an existing HTML page, recreating its layout in PDFKit usually costs more than using a browser.

6. CSS, pagination, fonts, and dynamic content

  • Put print-specific rules in @media print; Puppeteer defaults to print media.
  • Declare @page size and margins, then set preferCSSPageSize: true.
  • Use break-before, break-after, and break-inside: avoid for sections and tables.
  • Set printBackground: true when colored panels or chart fills matter.
  • Wait for document.fonts.ready and ensure font files are reachable.
  • For charts and hydration, wait for a stable selector such as [data-rendered='true'] rather than guessing with a short delay.
  • External images, stylesheets, and fonts need network access, valid certificates, and absolute URLs.

7. Deployment, reliability, and performance

Browser workers

Browser tools require a compatible browser binary, shared libraries, fonts, and enough memory. Install the browser during image build, keep one browser process per worker, and close pages in a finally block. Reusing a browser across jobs avoids repeated startup while isolating each job in a fresh page.

Serverless and containers

Check package size, cold-start limits, sandbox permissions, and writable temporary storage. A minimal container must include Chromium dependencies and the fonts your documents use. Avoid disabling the sandbox unless the runtime requires it.

Make jobs deterministic

Pin npm and browser versions, freeze locale and timezone where possible, use stable fixture URLs, and set explicit navigation and overall job timeouts. Record the URL, library version, browser version, and options with each output.

Cost model

Self-hosted Puppeteer and Playwright consume CPU, memory, storage, and engineering time for browser images and upgrades. PDFKit avoids browser overhead but shifts layout work into code. Hosted rendering can trade infrastructure work for per-capture pricing; compare operations, retries, and maintenance.

8. Troubleshooting checklist

Symptom Likely cause Fix
PDF is blank or half-rendered Capture ran before hydration or chart drawing Wait for a readiness selector, network idle, and fonts; increase timeout.
Looks different from the browser Print media rules or missing backgrounds Use emulateMediaType('screen') when appropriate and enable printBackground.
Wrong paper size or margins Conflicting @page and API options Set preferCSSPageSize deliberately and keep one source of truth.
Fonts fall back Font URL blocked or font absent Bundle or install fonts, use absolute URLs, wait for document.fonts.ready, and inspect network errors.
Navigation timeout Long polling, third-party requests, or blocked network Use a bounded timeout, wait for a specific selector, and block nonessential requests.
Browser fails in CI Missing binary/dependencies or sandbox restrictions Install the matching browser and OS libraries; configure sandbox flags only when required.
Footer overlaps content Bottom margin is too small Increase the bottom margin and keep the footer compact.
Table rows split badly Uncontrolled page breaks Apply break-inside: avoid to rows or groups and test long rows.
PDFKit cannot match CSS layout Wrong authoring model Switch to Puppeteer/Playwright or recreate the design explicitly with PDFKit.

9. Or skip the browser setup

ScreenshotNeo provides a hosted screenshot and PDF API. Its capture flow accepts consent banners and removes more than 60 known consent platforms, newsletter popups, and chat widgets before capture. Bot checks, blank pages, timeouts, failed loads, and cache hits are not billed, and response headers identify the page verdict and billing status. An MCP server exposes take_screenshot, get_page_info, and capture_pdf for Claude, Cursor, and other MCP clients.

See the ScreenshotNeo API docs for paper size, margins, landscape mode, page ranges, custom CSS and JavaScript, waits, headers, cookies, geolocation, caching, async jobs, and bulk capture.

curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o stripe.pdf
import requests
r = requests.get("https://api.screenshotneo.com/v1/shot", params={"access_key": "YOUR_API_KEY", "url": "https://stripe.com"}, timeout=90)
r.raise_for_status()
open("stripe.pdf", "wb").write(r.content)
const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://stripe.com' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);

There is a free plan with 1,000 screenshots a month and no card. Paid plans start at $5 for 3,000 shots, and every feature is available on every plan. Create a free ScreenshotNeo account.

10. FAQ

Can PDFKit convert a complete website?

No. It draws a document from your code. Use a browser engine when website CSS and JavaScript are the source.

Is html-pdf-node independent of Chromium?

No. It is a Puppeteer wrapper, so browser binaries, fonts, and sandboxing still apply.

Should I use screen or print media?

Use print for print-specific CSS and screen when the PDF should match the on-screen design. Make the choice explicit and test both.

What should I test before upgrading?

Render representative pages with long tables, missing data, web fonts, charts, images, and deliberate page breaks; compare PDFs visually and inspect page counts.