ego (lite) is just a browser, ego is your personal agent across devices.
Join waitlist
PlaywrightPuppeteerPDF generationHTML to PDFNode.js

Playwright vs Puppeteer for PDF Generation

Aug 16, 20268 min read
Playwright vs Puppeteer for PDF generation: same Chromium engine, different APIs

The short answer, before anything else: output quality is a tie, because Puppeteer's page.pdf() and Playwright's page.pdf() drive the same Chromium print-to-PDF engine. The decision lives around the render: Playwright adds tagged accessible PDFs, embedded outlines (both v1.42), and official Python, Java, and C# bindings; Puppeteer has the deeper HTML-to-PDF ecosystem for Node shops.

A neighboring job the tie doesn't cover: saving pages that already exist behind your logins as PDFs. For that shape, ego (lite) is free and skips the auth scripting: the agent inherits your signed-in sessions, drives Chromium's print-to-PDF through the ego-browser skill, and works in its own Space while your window stays yours.

Teams generating thousands of invoices, reports, and certificates daily keep landing on the same architecture: design in HTML and CSS, render to PDF in a real browser. Flexbox, Grid, web fonts, and designer handoff all come free.

Why is output quality a tie?

The puppeteer/puppeteer GitHub repository, JavaScript API for Chrome and Firefox, 95.5k stars
Contestant one: puppeteer/puppeteer, the Chrome DevTools team's library. For PDF generation its page.pdf() call drives Chromium's print-to-PDF machinery.

Both libraries send the same underlying command to the same renderer: Chromium lays the page out with print CSS media and produces the PDF. Playwright's docs describe the behavior plainly: page.pdf() generates a pdf of the page with print css media, and if you want the screen appearance instead, you call emulateMedia({ media: 'screen' }) first. Same story on the Puppeteer side.

Two shared quirks follow from that shared engine, and knowing them saves an afternoon each. First, colors: by default the engine prints with modified colors, and -webkit-print-color-adjust: exact is how you force your brand colors onto paper. Second, sizing: preferCSSPageSize lets your CSS @page rule win over the format option; leave it off and content scales to the paper size instead.

If you were hoping one tool would fix your page-break bugs: it won't, because the page breaks come from the same engine either way. Fix them in CSS once and both tools benefit.

Same engine, same PDF, same bugs. Pick on everything else.

Worth knowing what print media actually changes, since it surprises every first-time render team: screen-only styles vanish, print-specific rules activate, and layout reflows to the paper width rather than your viewport. If your invoice template was only ever tested in a browser tab, the first pdf() call is a genuinely different render, in both tools equally. Budget a styling pass against the print output itself.

How do the PDF APIs compare, option by option?

Read this table for the overlaps first; the two rows that differ are where the decision hides.

CapabilityPuppeteerPlaywright
Paper formats and custom sizesLetter through A6, width/height with unitsSame range, same unit handling (px, in, cm, mm)
Header/footer templatesHTML templates with date, title, url, pageNumber, totalPages classesIdentical template system, identical limits (no script evaluation, page styles invisible inside templates)
Backgrounds, ranges, scaleprintBackground, pageRanges, scale 0.1-2Same three, same defaults
Accessible (tagged) PDFsNot a first-class optiontagged: true, added v1.42; off by default
Embedded document outlineNot a first-class optionoutline: true, added v1.42

Those last two rows matter more than they look. If your PDFs face compliance requirements (government portals, accessibility audits, procurement checklists that say PDF/UA-ish things), tagged output stops being a nicety, and Playwright is the one with a switch for it.

For calibration, the minimal render is near-identical in both. Puppeteer:

const browser = await puppeteer.launch()
const page = await browser.newPage()
await page.setContent(invoiceHtml, { waitUntil: 'networkidle0' })
await page.pdf({ path: 'invoice.pdf', format: 'A4', printBackground: true })

And Playwright, with the two v1.42 switches on:

const browser = await chromium.launch()
const page = await browser.newPage()
await page.setContent(invoiceHtml, { waitUntil: 'networkidle' })
await page.pdf({ path: 'invoice.pdf', format: 'A4', printBackground: true,
  tagged: true, outline: true })

Which differences actually decide it?

The microsoft/playwright GitHub repository, web testing and automation for Chromium, Firefox and WebKit with a single API
Contestant two: microsoft/playwright. Same Chromium print pipeline underneath; the differences that matter for PDF work live in language bindings and options like tagged and outline.

Three, in descending order of how often they settle the choice.

1. Your service's language. Puppeteer is JavaScript/TypeScript only, and Pyppeteer (the unofficial Python port) lags releases with inconsistent maintenance. Playwright ships official, parity-complete APIs for JavaScript, Python, Java, and C#. A Django or Spring service that renders PDFs picks Playwright by default, and no benchmark needs consulting.

2. Compliance and accessibility needs. Covered above: tagged PDFs and outlines are Playwright switches. Replicating them in Puppeteer means post-processing with a PDF library like pdf-lib, which is a second dependency and a second place to break.

3. Existing code and muscle memory. Puppeteer has rendered invoices since 2017, and the internet's supply of HTML-to-PDF recipes, gotcha posts, and Stack Overflow answers skews heavily toward it. A Node shop with a working Puppeteer render pipeline gains nothing from migrating; the engine on the other side is the same one.

What about wkhtmltopdf?

It comes up in every PDF thread, so: wkhtmltopdf renders with a Qt WebKit engine that stopped tracking the modern web years ago. Practitioners' consistent report is that it's too outdated for modern CSS, and that page breaks plus Flexbox or Grid layouts are precisely where it falls apart, which are precisely the layouts you chose HTML for.

It still serves legacy pipelines rendering legacy templates; for anything designed this decade, the real decision is the one this article covers.

Old engine, old CSS, old problems.

What about one-off exports from logged-in pages?

Everything above assumes you're rendering your own HTML. A different job wears the same clothes: saving pages that already exist behind your logins (a vendor invoice portal, a SaaS report screen) as PDFs. Scripting that with Puppeteer or Playwright means scripting the login first, and maintaining it.

For that shape, an agent driving a real signed-in browser skips the auth work entirely. ego (lite) is a free browser built for sharing your logged-in browser state with AI agents like Claude Code and Codex: the agent works in its own Space with your sessions inherited, drives the browser through the ego-browser skill with full CDP access (Chromium's print-to-PDF included), and your window stays yours while it collects the documents.

Here's that CLI mechanism run for real, against a live page, today: a task space opens, navigates, and hands back exactly the fields asked for, not a page dump. The page below is public rather than login-gated, so it can't demonstrate the session-inheritance part directly, but the shell call, the Space, and the targeted return are the same ones a print-to-PDF run against a signed-in portal would use.

ego-browser nodejs <<'EOF'
const task = await egoBrowser.newTaskSpace('evidence-egobrowser-hn')
console.log({ taskSpaceId: task.id })

await task.page.goto('https://news.ycombinator.com/', { waitUntil: 'load', timeout: 20000 })
const title = await task.page.title()
const topStory = await task.page.locator('.athing .titleline > a').first().innerText()
const points = await task.page.locator('.subtext .score').first().innerText().catch(() => null)
console.log({ title, url: task.page.url(), topStory, points })
EOF

# Real output:
{
  "taskSpaceId": 13
}
{
  "title": "Hacker News",
  "url": "https://news.ycombinator.com/",
  "topStory": "Qwen 3.8 27B",
  "points": "412 points"
}

Wrong tool for rendering ten thousand invoices from your own templates; right tool for "grab this month's statements from the four portals I'm signed into."

Download ego (lite) for Mac, free, or see ego (lite) vs Puppeteer.

FAQ

Is Playwright or Puppeteer better for PDF quality?

Neither; both invoke Chromium's print-to-PDF engine, so rendering quality is identical. Differences live in the option surface (Playwright adds tagged PDFs and outlines in v1.42) and your service's language, not in the output.

Why do my PDF colors look washed out?

Both tools print with modified colors by default, per print conventions. Add -webkit-print-color-adjust: exact to your CSS and enable printBackground for background graphics; that combination restores brand colors in either tool.

Can I generate a PDF that looks like the screen, not print view?

Yes: call emulateMedia({ media: 'screen' }) (Playwright) or its Puppeteer equivalent before pdf(), otherwise the render uses print CSS media and your screen-only styles vanish.

Which is faster for PDF generation at scale?

No trustworthy public benchmark separates them on the pdf() call itself, and given the shared engine, a large gap would be surprising. The costs that actually dominate at scale are browser launch, page setup, and font loading, all of which respond to pooling and reuse identically in both tools. Optimize the lifecycle before believing any per-call speed claim.

How do I add page numbers and headers to the PDF?

Set displayHeaderFooter: true and supply headerTemplate/footerTemplate HTML using the built-in classes (date, title, url, pageNumber, totalPages); a span with class pageNumber renders the current page. Two documented limits apply in both tools: script tags inside templates aren't evaluated, and your page's styles aren't visible to templates, so inline the template's own styling.

Should I still use wkhtmltopdf in 2026?

Only for maintaining pipelines built on it. Its dated WebKit engine mishandles modern CSS, with page breaks and Flexbox/Grid the recurring casualties; new work belongs on a Chromium-based renderer through either library here.