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

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

Playwright vs Puppeteer vs WeasyPrint: real HTML-to-PDF latency and file size, Node.js and Python usage, macOS and Linux, plus the production gotchas inside.

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

Linux vs macOS

Same Playwright version, same HTML fixtures, different OS: Linux aarch64 (Debian 12 in Docker on an Apple Silicon host) is 30-70% slower than native macOS arm64. Most of the gap comes from Docker Desktop's LinuxKit VM overhead, not Chromium itself.

ScenariomacOS arm64Linux aarch64 (Docker)Delta
Simple, cold42ms62ms+48%
Complex, cold119ms127ms+7%
Simple, warm3ms4ms+33%
Complex, warm13ms22ms+69%

Two practical implications:

  • Benchmark on your target platform. Marketing numbers from macOS or bare-metal Linux can be 30-70% off what you get inside a container.
  • Warm pools matter more on Linux. The absolute warm-render cost is low either way, but the cold/warm ratio is the same order of magnitude (~15x for simple documents), so a persistent browser pool is still the biggest lever.

Both platforms use the same Playwright 1.58 Chromium build. Full Linux results are in .benchmark/results-linux.json.


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.

ARM64 Linux gotcha

Puppeteer does not ship a native ARM64 Linux Chrome binary. npx puppeteer browsers install chrome pulls the x86_64 build even on aarch64 hosts, and puppeteer.launch() then fails with rosetta error: failed to open elf at /lib64/ld-linux-x86-64.so.2 (or an equivalent ENOENT on the dynamic linker). If you deploy to AWS Graviton, an Apple Silicon CI runner, or any aarch64 Linux container, either install system Chromium and point Puppeteer at it with executablePath, or use Playwright, which ships a native aarch64 Chromium build and works unchanged on the same hosts.

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.

If you are deciding between WeasyPrint and Playwright specifically for a Python project (Django, Flask, FastAPI), the dedicated Playwright vs WeasyPrint comparison covers JavaScript support, CSS coverage gaps, and per-framework recommendations.

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()

Using these tools from Python

Python has three viable options in 2026: WeasyPrint for pure-Python rendering, playwright-python for Chromium-quality output with JavaScript support, and a managed API if you do not want to ship Chromium. pyppeteer is deprecated and should not be used in new code. The Chromium-side numbers above apply directly to Python because Playwright's Python binding drives the same Chromium binary as the Node version.

playwright-python

The official Python binding for Playwright, maintained by Microsoft. It exposes the same API as the Node version and drives the same Chromium binary over the same DevTools Protocol. The 42ms cold and 3ms warm numbers carry over because Chromium does the work regardless of which language calls into it.

from playwright.sync_api import sync_playwright
 
def render_pdf(html: str) -> bytes:
    with sync_playwright() as p:
        browser = p.chromium.launch()
        page = browser.new_page()
        page.set_content(html, wait_until="load")
        pdf = page.pdf(format="A4", print_background=True)
        browser.close()
        return pdf

Same warm-pool pattern as Node: launch chromium once at startup, reuse the browser across requests, only the per-request new_page() / page.pdf() / page.close() happens on the hot path. For async code, use playwright.async_api with the same shape.

pyppeteer: deprecated

pyppeteer was the Python port of Puppeteer. Its last release was in 2022 and it pins Chromium 2-3 years behind current. Security fixes and modern CSS features are not backported. Migrate to playwright-python: the API shape is similar and the Chromium version stays current.

WeasyPrint

Already covered above. Installs via pip install weasyprint plus system libraries (Pango, Cairo, GDK-PixBuf). Best fit for Django, Flask, or FastAPI apps producing invoices, reports, and letter-style documents where the source HTML has no JavaScript. Every render is cold (227ms simple, 629ms complex) because WeasyPrint spawns a new Python process per document, so warm-pool tricks do not apply.

Pure-Python alternatives

If you cannot ship Chromium (AWS Lambda layer limits, locked-down containers) and WeasyPrint's system dependencies are blocked:

  • ReportLab: programmatic PDF construction, not HTML-to-PDF. You draw on the page with Python calls. Powerful, verbose, no CSS.
  • xhtml2pdf: HTML/CSS subset built on ReportLab. Smaller CSS coverage than WeasyPrint, but a drop-in when WeasyPrint's system deps are unavailable.
  • fpdf2: lightweight, no external dependencies. Draw-oriented like ReportLab, not HTML-oriented.

None of these render JavaScript. If your templates rely on client-side rendering (charts, dynamic tables), only playwright-python produces correct output.

Choosing for Python projects

SituationRecommendation
Django, Flask, FastAPI with server-rendered HTML, no JSWeasyPrint
Templates with charts, dynamic tables, client-side renderingplaywright-python
AWS Lambda, Cloud Run, or tight layer limitsManaged API (PDF4.dev)
Locked-down environment, no Chromium and no Pango/Cairoxhtml2pdf or ReportLab
Existing pyppeteer codebaseMigrate to playwright-python

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

Primary run (macOS arm64)

  • Machine: MacBook Pro, Apple M-series (arm64), macOS 15
  • Node version: v22.22.0
  • Playwright version: 1.58.2
  • WeasyPrint version: 68.1
  • Raw results: .benchmark/results.json

Cross-platform check (Linux aarch64)

  • Machine: Debian 12 container (Docker Desktop on the same Apple Silicon host)
  • Node version: v20.18.2
  • Playwright version: 1.58
  • Puppeteer: skipped (no ARM64 Linux Chrome binary, see gotcha above)
  • Raw results: .benchmark/results-linux.json

Methodology

5 runs per scenario, median reported, min/max available in raw results. Cold-start timings include browser launch; warm timings reuse a shared browser across iterations. Same simple.html and complex.html fixtures across both platforms so the numbers are directly comparable. Reproduce with node .benchmark/bench.mjs.

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.