TL;DR. Choose Puppeteer when your HTML depends on JavaScript or you live in Node.js: it loads pages in real Chromium, runs the full V8 engine, and renders a complex invoice in 58 ms warm. Choose WeasyPrint when your HTML is server-rendered with no JavaScript and you live in Python: it is a lighter install, produces the smallest PDFs (21 KB versus Puppeteer's 197 KB on the same document), and needs no headless browser. The split is rarely about quality; it is about JavaScript, runtime, and who operates the infrastructure.
Puppeteer and WeasyPrint sit on opposite sides of two lines: Node.js versus Python, and Chromium versus a pure-CSS engine. This guide compares them on performance, JavaScript and CSS coverage, the cross-runtime question, and deployment, using numbers from the HTML to PDF benchmark 2026.
Quick comparison
WeasyPrint is the lighter Python CSS engine; Puppeteer is the Node.js Chromium driver that runs JavaScript. The table below is the fast version of the whole article.
| Factor | Puppeteer (v23) | WeasyPrint 68 |
|---|---|---|
| Runtime | Node.js | Python |
| Engine | Chromium (CDP over WebSocket) | Pure Python (Pango + Cairo) |
| JavaScript support | Full (V8) | None |
| CSS Grid / Flexbox | Full (Chromium) | Full Flexbox, partial Grid |
| Cold render (simple) | 147 ms | 227 ms |
| Cold render (complex) | 187 ms | 629 ms |
| Warm render (simple / complex) | 48 ms / 58 ms | Not applicable (no warm mode) |
| File size (simple / complex) | 18 KB / 197 KB | 8 KB / 21 KB |
| Install footprint | ~280 MB (Chromium binary) | ~30-50 MB (Python + Pango + Cairo) |
| ARM64 Linux | No native binary (needs system Chromium) | Works (pure Python + C libs) |
| Maintained by | Google (Chrome team) | Kozea + contributors |
The 30-second decision
Three questions settle most cases between Puppeteer and WeasyPrint.
- Does your HTML rely on JavaScript (charts, dynamic tables, a client-side framework)? If yes, Puppeteer. WeasyPrint runs no JavaScript, so the choice is made. Stop here.
- What language is your backend? Puppeteer is Node.js, WeasyPrint is Python. Crossing that line means running one as a subprocess or a separate service, which adds operational surface (see the cross-runtime section).
- Do you sustain more than about 5 PDFs per second? WeasyPrint's 227 ms floor saturates one CPU at 4-5 renders per second. Warm Puppeteer holds a persistent browser and clears that bar; beyond it, a warm pool or a managed API keeps up.
If your HTML is CSS-only, your stack is Python, and throughput is moderate, WeasyPrint is the simpler answer: smaller install, smaller PDFs, no browser to babysit. Anything with JavaScript, or a Node backend, points to Puppeteer.
Performance: real numbers
Warm Puppeteer is several times faster than cold WeasyPrint, and WeasyPrint files are far smaller. These figures come from the full HTML to PDF benchmark, measured on macOS arm64 with Node 22 and Python 3.12, median of 5 runs.
| Tool | Document | Mode | Latency | File size |
|---|---|---|---|---|
| Puppeteer v23 | Simple | Cold | 147 ms | 18 KB |
| Puppeteer v23 | Complex (50-row invoice) | Cold | 187 ms | 197 KB |
| Puppeteer v23 | Simple | Warm | 48 ms | 18 KB |
| Puppeteer v23 | Complex | Warm | 58 ms | 197 KB |
| WeasyPrint 68 | Simple | Cold only | 227 ms | 8 KB |
| WeasyPrint 68 | Complex | Cold only | 629 ms | 21 KB |
Two numbers decide a production setup:
- Warm Puppeteer is about 4.7x faster than WeasyPrint on simple documents (48 ms versus 227 ms) and about 10.8x faster on complex ones (58 ms versus 629 ms). The gap widens with document complexity because WeasyPrint re-runs its full layout per process while Puppeteer amortizes browser startup across renders.
- WeasyPrint produces 55-90% smaller PDFs (8 KB versus 18 KB simple, 21 KB versus 197 KB complex). It writes native PDF primitives; Puppeteer's output carries Chromium's print-to-PDF artifacts and embedded fonts.
WeasyPrint has no warm mode. Each HTML(string=html).write_pdf() call runs a self-contained render through the Pango and Cairo bindings, so there is nothing to keep alive between calls. Puppeteer's warm path depends on holding one browser instance open and opening a fresh page per render.
Architecture: why they behave differently
Puppeteer drives a real Chromium process; WeasyPrint is a Python renderer with no browser. That single difference explains the speed, the file sizes, and the JavaScript gap.
Puppeteer launches Chromium and talks to it over the Chrome DevTools Protocol. Your HTML loads as a web page, V8 runs any scripts, Blink lays out the CSS, and Chromium's print-to-PDF code path emits the file. You get everything a browser renders, plus a browser's memory and disk footprint.
WeasyPrint is a pure Python implementation of CSS 2.1 paged media and parts of CSS Level 3. It parses HTML, builds a render tree, applies CSS, calls Pango for text shaping, and writes PDF through Cairo. There is no browser, no V8, no DOM beyond what CSS needs. You get exactly what HTML and CSS describe, nothing more.
The performance shape follows from this. Chromium reuses V8 isolates and resource caches across renders in one browser, so a warm Puppeteer render costs 48 ms instead of the 147 ms cold launch. WeasyPrint cannot amortize anything across renders, so its 227 ms is paid every time.
JavaScript support: the usual dealbreaker
This is the cleanest split. WeasyPrint runs no JavaScript; Puppeteer runs all of it.
WeasyPrint renders whatever the server emits in the HTML. A Chart.js chart that draws itself in a script tag becomes an empty canvas element. A React app that hydrates client-side becomes the unhydrated server shell, or an empty div id="root". The same applies to D3, Plotly, Mermaid, and any "render after DOM ready" pattern.
Puppeteer runs the full V8 engine. Charts draw, components hydrate, and you can await page.waitForSelector() to hold for late content before calling page.pdf(). That is the difference between "static HTML to PDF" and "anything a browser would render to PDF".
If you are on Python and want WeasyPrint but need a chart, render it server-side as inline SVG (matplotlib, Vega-Lite, or Plotly's to_image()), which WeasyPrint embeds cleanly. If you cannot render charts server-side, the decision is made for you: use Puppeteer.
CSS coverage: where each tool stops
Both render the common subset (colors, typography, tables, page breaks, headers, footers) correctly. Differences appear at recent CSS additions, where Chromium leads.
| Feature | Puppeteer (Chromium) | WeasyPrint 68 |
|---|---|---|
@page, page-break-*, widows / orphans | Full | Full |
| Flexbox | Full | Full |
| CSS Grid (basic) | Full | Mostly |
subgrid, advanced grid-template-areas | Full | Partial |
| Container Queries | Full | Partial (68+) |
transform: rotate / scale | Full | Full |
| CSS variables | Full | Full |
@font-face (Google Fonts) | Yes (wait for network idle) | Yes (via Pango font cache) |
| SVG embedding | Full | Full |
| MathML | Limited | Limited |
For invoices, receipts, contracts, and letters, both render the same once fonts match. Differences show up in heavily designed PDFs that lean on new CSS. For a hands-on tour of print CSS, see the CSS print styles guide.
The cross-runtime catch: Node versus Python
Puppeteer and WeasyPrint are not drop-in swaps because they live in different runtimes. Picking between them often means picking your backend language first.
Puppeteer is a Node.js package; it has no Python API. WeasyPrint is a Python package; it has no Node binding. If your service is Node and you want WeasyPrint's small CSS-only output, you run WeasyPrint as a subprocess through its CLI (weasyprint input.html output.pdf) or stand up a small Python service and call it over HTTP. If your service is Python and you want Puppeteer's JavaScript support, the closest in-language option is playwright-python, since pyppeteer (the old Python port of Puppeteer) has been unmaintained since 2022 and pins an outdated Chromium.
So the practical matrix is less "Puppeteer or WeasyPrint" and more "what is my runtime, and do I need JavaScript":
| Your backend | Need JavaScript? | Pick |
|---|---|---|
| Node.js | Yes | Puppeteer (or Playwright for lower warm latency) |
| Node.js | No | Puppeteer, or a managed API for smaller ops surface |
| Python | Yes | playwright-python |
| Python | No | WeasyPrint |
| Any language, no JavaScript, want one HTTP call | Either | Managed API |
Code: side-by-side
The minimal "render this HTML to a PDF buffer" in each, with Puppeteer using a warm browser and WeasyPrint as its natural one-liner.
import puppeteer from "puppeteer";
let browserPromise = null;
function getBrowser() {
if (!browserPromise) {
browserPromise = puppeteer.launch({ headless: true });
}
return browserPromise;
}
export async function renderPdf(html) {
const browser = await getBrowser();
const page = await browser.newPage();
try {
await page.setContent(html, { waitUntil: "load" });
return await page.pdf({
format: "A4",
printBackground: true,
margin: { top: "20mm", bottom: "20mm", left: "15mm", right: "15mm" },
});
} finally {
await page.close();
}
}Puppeteer needs the warm-pool bookkeeping to reach 48 ms; cold launches cost 147 ms each. WeasyPrint is one line in Python because there is no browser to keep alive, but calling it from Node means shelling out and shipping Pango and Cairo on the host.
Production: what breaks for each
Both run in production; neither is a one-line ops story. The failure modes differ.
Puppeteer pain points
- Binary size:
npx puppeteer browsers install chromepulls roughly 280 MB. Across CI runs and Docker layers, this adds up. - ARM64 Linux: Puppeteer ships no native aarch64 Chrome binary.
puppeteer.launch()fails on AWS Graviton or Apple Silicon containers with arosetta erroron the dynamic linker. Install system Chromium and setexecutablePath, or use Playwright. - Memory and crashes: each Chromium instance holds ~150 MB resident, and headless Chromium will OOM under memory pressure, so a warm pool needs restart logic.
- Page leaks: a missing
page.close()in an exception path leaks memory until the browser is recycled. - Serverless: Lambda's 250 MB unzipped limit excludes a vanilla Chromium; you need a compressed binary or a container image.
WeasyPrint pain points
- System dependencies: Pango, Cairo, and GDK-PixBuf install at the OS level. On Debian:
apt install libpango-1.0-0 libpangoft2-1.0-0 libcairo2 libgdk-pixbuf-2.0-0. On Alpine (musl) it is more involved. - Fonts: WeasyPrint uses fonts in the Pango fontconfig cache. A container without your typeface falls back to a default sans; add
apt install fonts-noto fonts-liberationor mount font files and rebuild the cache. - Throughput ceiling: the 227 ms cold floor caps one CPU at about 4-5 PDFs per second. Beyond that, scale horizontally with more processes.
- Silent CSS drops: malformed CSS often renders with the offending rule dropped and no error. Debug with WeasyPrint logging at DEBUG level.
Choosing in 2026
A short decision guide once the runtime and JavaScript questions are answered.
| Situation | Recommendation |
|---|---|
| Node app, charts or client-side framework in templates | Puppeteer with a warm pool |
| Node app, lowest warm latency wanted | Playwright (3 ms versus Puppeteer's 48 ms warm, simple doc) |
| Existing Puppeteer codebase | Keep Puppeteer; migrate only if latency matters |
| Python app, server-rendered HTML, no JavaScript | WeasyPrint |
| Python app, charts or hydration | playwright-python |
| AWS Lambda or Vercel, any language | Managed API (both libraries fight the platform) |
| ARM64 Linux without system Chromium | Playwright or WeasyPrint (Puppeteer needs setup) |
The deeper axis is not "which renders better": for CSS-only documents Puppeteer and WeasyPrint produce equivalent output. The axis is JavaScript, runtime, and whether you want to operate a headless browser or a Pango toolchain at all. For the Node-side Chromium comparison, see Playwright vs Puppeteer for PDF generation; for the Python-side Chromium comparison, see Playwright vs WeasyPrint.
Skip the choice: managed API
If you do not want to ship 280 MB of Chromium, and you do not want to install Pango on every container, a managed HTML-to-PDF API trades that infrastructure for a per-render cost and a single HTTP call. It also sidesteps the cross-runtime problem: the same endpoint works from Node, Python, Go, or anything that can POST JSON.
export async function renderPdf(html) {
const res = await fetch("https://pdf4.dev/api/v1/render", {
method: "POST",
headers: {
Authorization: "Bearer p4_live_xxx",
"Content-Type": "application/json",
},
body: JSON.stringify({ html, format: { preset: "a4" } }),
});
if (!res.ok) throw new Error(`Render failed: ${res.status}`);
return Buffer.from(await res.arrayBuffer());
}PDF4.dev runs warm Chromium pools, so you get a warm-render path on every request without managing browsers, Cairo, or Lambda layers, from any language. Try it free, no credit card. You can also test a render in the browser with the free HTML to PDF tool.
The trade-off is the usual one: latency now includes a network round-trip (typically 50-150 ms from a US or EU server), and you depend on an external service. In return you ship none of Chromium, none of Pango, no warm-pool code, and no crash recovery.
Summary
- JavaScript in your HTML? Puppeteer. WeasyPrint runs none.
- Python backend, CSS-only HTML, moderate volume? WeasyPrint. Lighter install, smaller PDFs, no browser.
- Node backend, charts or frameworks? Puppeteer warm pool, or Playwright for lower latency.
- Smallest files? WeasyPrint (8 KB simple, 21 KB complex versus Puppeteer's 18 KB and 197 KB).
- ARM64 Linux, Lambda, or any language? A managed API avoids the binary and the system-library problem.
For the full three-way numbers including Playwright, see the HTML to PDF benchmark 2026, and for the Node.js starting point, see Generate PDFs from HTML in Node.js.
Free tools mentioned:
Start generating PDFs
Build PDF templates with a visual editor. Render them via API from any language in ~300ms.


