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
| Tool | Document | Mode | Latency (median) | File size |
|---|---|---|---|---|
| Playwright | Simple | Cold | 42ms | 16 KB |
| Playwright | Complex | Cold | 119ms | 59 KB |
| Playwright | Simple | Warm | 3ms | 16 KB |
| Playwright | Complex | Warm | 13ms | 125 KB |
| Puppeteer | Simple | Cold | 147ms | 18 KB |
| Puppeteer | Complex | Cold | 187ms | 197 KB |
| Puppeteer | Simple | Warm | 48ms | 18 KB |
| Puppeteer | Complex | Warm | 58ms | 197 KB |
| WeasyPrint | Simple | Cold only | 227ms | 8 KB |
| WeasyPrint | Complex | Cold only | 629ms | 21 KB |
Warm vs cold speedup
| Tool | Simple doc | Complex doc |
|---|---|---|
| Playwright | 14x faster (42ms → 3ms) | 9x faster (119ms → 13ms) |
| Puppeteer | 3x faster (147ms → 48ms) | 3x faster (187ms → 58ms) |
| WeasyPrint | no warm mode | no 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
| Situation | Recommendation |
|---|---|
| New project, Node.js stack | Playwright with warm browser pool |
| Existing Puppeteer codebase | Keep Puppeteer, or migrate if performance matters |
| Python stack, no JS in templates | WeasyPrint |
| Serverless (Lambda, Vercel Edge) | Managed API (PDF4.dev) |
| High volume (1K+ PDFs/day) | Managed API or dedicated Playwright server |
| New project, any language | Managed API |
| Legacy project using wkhtmltopdf | Migrate 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.jsonin the pdf4dev/benchmarks repository
Free tools mentioned:
Start generating PDFs
Build PDF templates with a visual editor. Render them via API from any language in ~300ms.