Get started

HTML to PDF benchmark 2026 (Playwright vs Puppeteer vs WeasyPrint)

Real render latency and file size numbers for Playwright, Puppeteer, and WeasyPrint — run on macOS arm64, Node v22. See which tool breaks first in production.

benoitdedMarch 17, 20269 min read

HTML to PDF conversion looks solved until you actually put it in production. Every tool works fine on a developer's laptop. The cracks appear when you hit 50 concurrent requests, deploy to a serverless function, or try to build a Docker image under 200MB.

This benchmark measures what actually matters: render latency and output file size, across the three tools you are most likely to reach for in 2026. All numbers were collected on macOS arm64, Node v22.22.0, with the median of 5 runs per test. The raw results are available on GitHub for reproducibility.

Tools tested:

  • Playwright (Chromium, v1.58): Microsoft's modern headless browser library
  • Puppeteer (Chromium, v23): Google's original headless Chrome API
  • WeasyPrint (Python, v68): a CSS-based PDF renderer that does not use a browser engine
  • wkhtmltopdf: deprecated in 2023, included for historical reference only

Test methodology

Two document types cover the realistic performance range.

Simple document: a single-page HTML file with a heading, two paragraphs, and minimal CSS. No images, no complex layout. Represents invoices, receipts, or letter-style outputs.

Complex document: a 50-row invoice table with alternating row colors, CSS layout, and multiple font weights. Represents the typical business document generation workload.

For Playwright and Puppeteer, two scenarios were measured:

  • Cold start: a new browser process is launched for every render, then shut down. Simulates a serverless function with no persistent instance.
  • Warm: a single browser instance is kept alive and reused across renders. Simulates a long-running server or a browser pool.

WeasyPrint has no warm mode. Each render spawns a new Python process. This is a fundamental architectural constraint.


Results

Full benchmark results

ToolDocumentModeLatency (median)File size
PlaywrightSimpleCold42ms16 KB
PlaywrightComplexCold119ms59 KB
PlaywrightSimpleWarm3ms16 KB
PlaywrightComplexWarm13ms125 KB
PuppeteerSimpleCold147ms18 KB
PuppeteerComplexCold187ms197 KB
PuppeteerSimpleWarm48ms18 KB
PuppeteerComplexWarm58ms197 KB
WeasyPrintSimpleCold only227ms8 KB
WeasyPrintComplexCold only629ms21 KB

Warm vs cold speedup

ToolSimple docComplex doc
Playwright14x faster (42ms → 3ms)9x faster (119ms → 13ms)
Puppeteer3x faster (147ms → 48ms)3x faster (187ms → 58ms)
WeasyPrintno warm modeno warm mode

Playwright's warm performance is exceptional: 3ms for a simple document is faster than most database queries. Puppeteer's warm improvement is more modest at 3x, which suggests more per-render overhead in its internal architecture.


Playwright

Playwright is the fastest option in warm mode (3ms simple, 13ms complex) and the most capable for CSS rendering. The main cost is the initial browser launch and the dependency footprint.

Architecture

Playwright controls Chromium via the DevTools Protocol and reuses connections efficiently across page renders. The page.pdf() call delegates directly to Chromium's print-to-PDF engine, giving you full CSS support, JavaScript execution, and accurate font rendering.

Cold start breakdown

The 42ms cold-start for a simple document breaks down roughly as:

  • Browser process launch: 30–35ms
  • Page creation and HTML injection: 5ms
  • page.pdf() render: 2–3ms

The browser launch is the dominant cost. On Linux in Docker, this is typically 80–120ms because of additional system library loading.

Warm path

With a persistent browser (one chromium.launch() call at server startup), the render cost drops to 3ms for simple documents and 13ms for complex ones. You only pay for page creation and rendering.

import { chromium, Browser } from 'playwright'
 
let browser: Browser | null = null
 
async function getBrowser(): Promise<Browser> {
  if (!browser || !browser.isConnected()) {
    browser = await chromium.launch({ headless: true })
  }
  return browser
}
 
export async function renderPdf(html: string): Promise<Buffer> {
  const b = await getBrowser()
  const page = await b.newPage()
  try {
    await page.setContent(html, { waitUntil: 'networkidle' })
    const pdf = await page.pdf({
      format: 'A4',
      printBackground: true,
      margin: { top: '20mm', bottom: '20mm', left: '15mm', right: '15mm' },
    })
    return Buffer.from(pdf)
  } finally {
    await page.close()
  }
}

Where Playwright breaks in production

  • Docker image size: 300–500MB with Chromium and all system dependencies (libglib, libnss, libatk, libcups, fonts)
  • Serverless: cold starts of 80–120ms on Linux. AWS Lambda and Vercel Edge Functions require special layers or runtimes
  • Memory: Chromium uses ~150MB per browser instance plus ~30MB per active page
  • Browser crashes: headless Chromium crashes under memory pressure. You need restart logic and health checks
  • Concurrency: a single browser can handle ~5–10 parallel pages before degradation. Beyond that, you need a pool

Puppeteer

Puppeteer is slower than Playwright at every data point — 147ms vs 42ms cold, 48ms vs 3ms warm. If you are starting a new project today, use Playwright. Puppeteer remains relevant for existing codebases.

Why it is slower

Both tools call Chromium's DevTools Protocol. The warm-path gap (48ms vs 3ms) suggests Puppeteer has more per-render overhead in its page lifecycle management and connection handling. The cold-start gap (147ms vs 42ms) is even larger.

The file size difference is notable on complex documents: Puppeteer produces 197KB versus Playwright's 59–125KB for the same HTML. This points to different PDF compression strategies.

When to use Puppeteer

  • You have an existing Puppeteer codebase and the migration cost outweighs the performance gains
  • You need specific Puppeteer plugins or integrations not available for Playwright
  • You are already familiar with the API and building a low-volume internal tool
import puppeteer, { Browser } from 'puppeteer'
 
let browser: Browser | null = null
 
async function getBrowser(): Promise<Browser> {
  if (!browser) {
    browser = await puppeteer.launch({
      headless: true,
      args: ['--no-sandbox', '--disable-setuid-sandbox'],
    })
  }
  return browser
}
 
export async function renderPdf(html: string): Promise<Buffer> {
  const b = await getBrowser()
  const page = await b.newPage()
  try {
    await page.setContent(html, { waitUntil: 'networkidle0' })
    const pdf = await page.pdf({
      format: 'A4',
      printBackground: true,
      margin: { top: '20mm', bottom: '20mm', left: '15mm', right: '15mm' },
    })
    return pdf
  } finally {
    await page.close()
  }
}

WeasyPrint

WeasyPrint is the right choice when you need small PDF files and are comfortable with Python, but you cannot use Chromium. It produces the most compact output (8KB vs 16KB for simple documents) but is 75x slower than warm Playwright.

Architecture

WeasyPrint is a pure Python PDF renderer. Instead of running a browser, it parses HTML and CSS directly, then generates PDF primitives. There is no JavaScript execution, no browser engine, and no warm mode since each render is a separate process.

File size advantage

WeasyPrint's output is dramatically smaller: 8KB for a simple document versus 16KB for Playwright, and 21KB versus 59–125KB for the complex document. This matters for use cases where you are storing or emailing large volumes of PDFs and every kilobyte counts.

Where WeasyPrint breaks

  • No JavaScript support: any JS-rendered content (charts, dynamic tables, client-side rendering) will not appear in the PDF
  • Modern CSS gaps: WeasyPrint implements CSS Paged Media Level 3 but some modern properties (CSS Grid complex layouts, certain flexbox edge cases) have incomplete support
  • No warm mode: 227ms per render, always. At 100 renders per second you are looking at 22.7 seconds of compute time versus 0.3 seconds for warm Playwright
  • Python dependency: adds complexity to Node.js or Go stacks
from weasyprint import HTML
 
def render_pdf(html: str) -> bytes:
    return HTML(string=html).write_pdf()

wkhtmltopdf: do not use it

wkhtmltopdf was officially deprecated in 2023. The project has no active maintainers, no arm64 binaries exist for macOS or Linux, and the underlying Qt WebKit engine is years behind modern CSS standards. It fails on display: grid, CSS custom properties, and many @page rules.

If you have an existing wkhtmltopdf integration, migrate to Playwright. The API is different but the concepts are the same: load HTML, call the PDF render method, return the buffer.


The production problem nobody warns you about

The benchmark numbers above are for a single render on a developer machine. In production, the challenge is not raw speed — it is reliability at scale.

Running Playwright yourself means:

  • Browser crashes under memory pressure. You need a crash detection loop and automatic restart logic.
  • Page leak bugs. If an error path does not call page.close(), memory grows until the process dies.
  • Docker image bloat. Every deployment ships 300–500MB of Chromium binaries. CI/CD gets slower.
  • Serverless incompatibility. AWS Lambda has a 250MB layer limit. Vercel Edge Functions do not support Chromium at all.
  • Concurrency management. You need a queue and a browser pool. Otherwise, burst traffic kills your server.

These are solvable problems. Teams do solve them. But they take engineering time that could go toward product features.

PDF4.dev is a managed Playwright API. You send HTML, we return a PDF. No browser management, no Docker overhead, no crash recovery. The benchmark warm-path latency (3ms render) is what you get on every request. Try it free — no credit card required.


Choosing the right tool

SituationRecommendation
New project, Node.js stackPlaywright with warm browser pool
Existing Puppeteer codebaseKeep Puppeteer, or migrate if performance matters
Python stack, no JS in templatesWeasyPrint
Serverless (Lambda, Vercel Edge)Managed API (PDF4.dev)
High volume (1K+ PDFs/day)Managed API or dedicated Playwright server
New project, any languageManaged API
Legacy project using wkhtmltopdfMigrate to Playwright now

The decision axis is not "which tool is technically better" — it is "do I want to manage this infrastructure?" Playwright and Puppeteer use the same Chromium engine. The difference is whether you run it yourself or someone else does.


Benchmark details

  • Machine: MacBook Pro, Apple M-series (arm64), macOS 15
  • Node version: v22.22.0
  • Playwright version: 1.58.2
  • WeasyPrint version: 68.1
  • Methodology: 5 runs per scenario, median reported, min/max available in raw results
  • Raw results: .benchmark/results.json in the pdf4dev/benchmarks repository

Free tools mentioned:

Html To PdfTry it free

Start generating PDFs

Build PDF templates with a visual editor. Render them via API from any language in ~300ms.