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. Neither one carries your login, so for a one-off export of an already signed-in page, hand it to ego (lite), a full Chromium that exports the page as the browser actually rendered it.

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.

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.

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

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 working inside a real signed-in browser skips the auth work entirely. ego (lite) is a full Chromium, so the export shows the page as a browser actually renders it.

The agent side of that mechanism is measured in the open: on Real-World Bench (a 31-task suite against live sites, same model and same judge for every tool), ego (lite) was the fastest agent browser, finishing 93.5% of 31 tasks perfectly at $1.64 average model cost per task, or $1.75 per completed task ($1.64 ÷ 93.5%). One of its tasks is document work of exactly this flavor: running Bankrate's compound savings calculator and filing the results into an online spreadsheet. Harness and dataset:citrolabs/ego-browser-benchmark-framework.

A recorded ego-browser session shows the shape: one shell call opens a task space, the script navigates, and only the requested fields come back. Swap the extraction lines for a page.pdf() call and point it at an invoice page you're signed into, and that is the whole export workflow; the target below is public only because a demo can't show your logins.

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.

How do you render dynamic pages and charts reliably?

Wait for the application state, not an arbitrary timeout. After navigation, wait for the chart or React component to report its loaded state, call document.fonts.ready, and verify that images and data requests have completed before page.pdf(). For Chart.js or canvas output, capture after the final animation frame and use print CSS to set a deterministic size.

Production renderers should bound page and browser lifetimes, reuse a small browser pool, and record the HTML version, Chromium version, viewport, fonts, and wait condition with every file. A cold-start crash or memory spike is an operational problem shared by Playwright and Puppeteer, not a reason to hide missing content with a longer sleep.

Why do automated PDF workflows produce blank or broken files?

Blank PDFs usually mean the export ran before the form data, fonts, images, or client-side render finished. Broken Zapier-style attachments often come from a test payload that differs from the live webhook, an expired temporary URL, or a step that returns before the PDF bytes are available. Log the input ID, final URL, response content type, byte count, and renderer error.

Make the workflow idempotent: validate required fields, render from a versioned template, wait for a known ready signal, and attach the generated bytes only after a checksum or non-zero size check. Keep a failed job visible for retry instead of sending an empty attachment downstream.

What lightweight HTML-to-PDF alternatives avoid Chromium?

Non-Chromium engines can be a good fit for simple, static documents: a server-side PDF library, a vector renderer, or a language-specific HTML-to-PDF gem can start faster and use less memory. They often support fewer modern CSS features, JavaScript behaviors, web fonts, and iframe-based content than a current browser.

Choose a lightweight renderer when your template is stable and you control the CSS. Choose Playwright or Puppeteer when fidelity to a live web page, charts, client-side data, or print CSS matters. Benchmark representative invoices and page breaks before committing to either path.

Should the final PDF use a fixed template or AI-generated layout?

Use a versioned HTML/CSS template for invoices, certificates, statements, and any document with legal, financial, or brand requirements. Let AI help draft copy, map fields, or propose a layout, but validate the final values, page breaks, accessibility tags, and approval status before generating the file.

An agent-driven browser export is useful for a one-off report from a page you already opened, especially when the data is behind your login. Keep the source URL and timestamp with the PDF, redact private data before sharing, and never treat an unreviewed AI layout as an authoritative record.

How should you compare pay-per-use PDF APIs with self-hosting?

Compare total cost per successful PDF, not only the advertised render price. Include browser cold starts, compute, storage, retries, queueing, observability, and the engineering time to keep templates and Chromium versions healthy. Pay-per-use APIs reduce operations for bursty workloads; self-hosting wins when volume is steady and data must stay inside your network.

Ask each provider about page limits, fonts, JavaScript support, retention, regional processing, and SLA. For a small local export from an authenticated page, a visible browser workflow may be cheaper than adding a subscription; for thousands of scheduled documents, a managed API or worker pool is usually easier to operate.

How do Django, Ruby, and other frameworks fit this choice?

Keep PDF generation at the edge of your application: render a framework template to a stable URL or HTML string, then hand it to Playwright or Puppeteer in a worker. Django teams commonly pair a Python Playwright service with their templates; Ruby teams may use a gem or a Node render worker. The important contract is deterministic input, ready-state signaling, and a streamed PDF response.

Test external CSS, images, fonts, and print media from the same deployment environment used in production. Pin the browser version, pass a correlation ID through the job, and retain only the minimum document data needed for retries and audit.

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.