Skip to main content

HTML to PDF in Node.js: Puppeteer vs wkhtmltopdf vs API Services

ยท 5 min read
Will Lamerton

If you've ever had to generate a PDF from HTML in a Node.js app โ€” an invoice, a report, a contract โ€” you've probably hit the same fork in the road. Do you spin up Puppeteer, reach for the old reliable wkhtmltopdf, or pay someone else to run it?

This post walks through the trade-offs honestly. We build HTML-to-PDF.io, so we have a side, but the goal here is to help you pick what actually fits your project โ€” sometimes that's us, sometimes it isn't.

The three options at a glanceโ€‹

PuppeteerwkhtmltopdfAPI service
Rendering engineHeadless ChromiumWebKit (old)Headless Chromium
Setup timeHours to daysHoursMinutes
Bundle size~170MB Chromium binary~40MB binaryNone (HTTP)
Modern CSS supportExcellent (Chrome)Limited (flexbox quirks, no CSS grid)Excellent (Chrome)
Runs on Lambda / VercelPainful (layer hacks)Painful (binary hacks)Yes (it's HTTP)
Cold start2โ€“5s1โ€“2sNone on your side
Memory footprint512MB โ€“ 2GB256MB โ€“ 1GBNone on your side
Cost modelCompute + ops timeCompute + ops timePer request
Maintenance burdenHighLow (but stale)None

Option 1: Puppeteerโ€‹

Puppeteer drives a real Chromium instance, so whatever you can render in Chrome, you can render to PDF. That includes modern CSS, web fonts, custom JavaScript, charts โ€” the works.

import puppeteer from "puppeteer"

const browser = await puppeteer.launch()
const page = await browser.newPage()
await page.setContent("<html><body><h1>Hello World</h1></body></html>")
const pdf = await page.pdf({ format: "A4" })
await browser.close()

It looks simple. It isn't.

Where Puppeteer hurts:

  • Chromium is heavy. The download is ~170MB, the running process eats 300MB+ of RAM, and you need to keep it patched.
  • Serverless is hostile to it. AWS Lambda's 250MB unzipped layer limit means you need @sparticuz/chromium or similar, and even then cold starts are 2โ€“5 seconds.
  • Concurrency is painful. A browser instance per request is expensive; a pool of reusable pages is fiddly and leaks memory if you get it wrong.
  • Ops burden. Chromium ships security patches monthly. If you're running it in production, you own that update cadence.

Pick Puppeteer if: you already have a long-running Node process (not serverless), you generate PDFs in high enough volume that a managed service costs more than your engineer time, and you have someone willing to own the Chromium upgrade treadmill.

Option 2: wkhtmltopdfโ€‹

The old reliable. wkhtmltopdf is a binary that wraps an old WebKit build to render HTML to PDF. It's been the default answer for over a decade.

import { exec } from "child_process"

exec("wkhtmltopdf input.html output.pdf", (err) => { /* ... */ })

It's smaller and faster to start than Puppeteer, and there's a Node wrapper (wkhtmltopdf on npm). But it carries a heavy caveat.

Where wkhtmltopdf hurts:

  • It's effectively unmaintained. The project archived its repository in 2023. No security patches, no bug fixes, no modern CSS additions.
  • Old WebKit means broken CSS. Flexbox is partial. CSS grid doesn't exist. Modern fonts and @media print rules behave inconsistently. If your HTML uses anything from the last five years of CSS, expect surprises.
  • Still binary-heavy. You can't just npm install it โ€” you need the right binary for your OS/architecture, which complicates Docker images and CI.

Pick wkhtmltopdf if: you have legacy HTML that already renders correctly under it and you don't want to migrate. For new projects, the unmaintained status is hard to justify.

Option 3: A managed HTML to PDF APIโ€‹

The third option is to skip running a renderer yourself and hit an API. Send HTML, get back a PDF.

import { htmlToPdf } from "@html-to-pdf.io/sdk-javascript"

const { pdfData } = await htmlToPdf({
apiKey: process.env.HTML_TO_PDF_API_KEY,
html: "<html><body><h1>Hello World</h1></body></html>",
})

That's it. No Chromium binary in your bundle, no Lambda layer hacks, no upgrade treadmill. Modern CSS support because it's still Chrome under the hood โ€” just running on someone else's infrastructure.

Where it hurts:

  • You're paying per request. Above a certain volume, running your own Puppeteer is cheaper on raw compute.
  • You're outsourcing reliability. The provider's downtime is your downtime. (We publish ours at status.html-to-pdf.io.)
  • Network latency. An HTTP round-trip adds ~50โ€“200ms vs. local rendering โ€” usually invisible to users, but worth knowing.

Pick a managed API if: you're on serverless (Lambda, Vercel, Cloudflare Workers), your PDF volume is moderate, or your team would rather ship features than maintain a Chromium fork.

The honest answerโ€‹

For most teams shipping new products in 2026, the maths has shifted. Engineer hours cost more than they used to, serverless platforms have become the default, and the operational cost of running Chromium in production is real even when the compute is "free."

If you're rendering hundreds of thousands of PDFs a day, self-hosting Puppeteer makes sense โ€” at that scale you have a dedicated team to own it. If you're rendering ten or ten thousand a day, paying a few cents per document to skip the entire problem is a better trade.

Try itโ€‹

If you want to see what the output looks like before committing, we run a free converter at html-to-pdf.io/convert โ€” paste HTML, get a PDF. No signup.

For the API itself, the docs are here, and pricing starts well below what most teams expect.