Playwright for HTML to PDF conversion in Node.js: the 2026 guide
The fastest way to convert HTML to PDF with Playwright in Node.js is to npm install playwright, launch Chromium with playwright.chromium.launch(), load the page with page.goto(), and call page.pdf({ format: 'A4' }). The catch: page.pdf() only works in the Chromium browser context (not Firefox or WebKit), and running Playwright on AWS Lambda adds 5 to 15 seconds of cold start to every render. Use it when you already run Playwright for tests; reach for a hosted Chrome engine the moment that operational cost outgrows the per-render cost.
Key Takeaways
- Playwright'spage.pdf()is Chromium-only. Firefox and WebKit don't support it. Playwright's cross-browser functionality covers navigation and testing, not PDF generation.
- Wait semantics matter.waitForLoadState('networkidle')works for simple pages;waitForSelectoris the right answer when the page paints client-side or runs background activity that never goes idle.
- Production cost is real. AWS Lambda cold starts for Chromium-based functions hit 5 to 15 seconds. The Chromium binary doesn't fit in a default Lambda layer (250 MB unzipped); use a container image orchrome-aws-lambda+playwright-core.
- Migrate to a hosted engine when you don't want to operate a browser farm. Thewait_forparameter on hosted engines maps directly to Playwright'swaitForSelector, which keeps the migration to about 15 lines of code.
Playwright's PDF feature is Chromium-only. The other two browsers you bought the cross-browser story for can't do it. That's the honest shape of the tool, and it's worth knowing before you commit a Friday afternoon to standing it up. Playwright is genuinely the best choice for teams that already run it for tests and want one more output from the same framework; the API is clean, the option set is the most complete of any browser-automation library, and v1.42 added clickable PDF bookmarks ahead of Puppeteer. This guide covers the full path: the minimal page.pdf() call, the complete options reference, the wait semantics that decide whether your PDF renders or blanks, the Playwright vs Puppeteer math for the PDF use case, the AWS Lambda cost in real numbers, and the migration to a hosted engine when running your own browser stops being worth it.
How to convert HTML to PDF with Playwright
Three commands and six lines of code get you to a PDF. Install Playwright, install the Chromium binary, and write a script that loads a page and calls page.pdf().
npm install playwright
npx playwright install chromium
import { chromium } from 'playwright';
const browser = await chromium.launch();
const page = await browser.newPage();
await page.setContent('<h1>Invoice #1041</h1><p>Total: $49.00</p>');
await page.pdf({ path: 'out.pdf', format: 'A4' });
await browser.close();
That's the entire minimum. Three function calls plus setup and teardown. Save the file as script.mjs, run node script.mjs, and you have an out.pdf next to the script.
Two pieces are worth defending. The chromium import (not the generic playwright export) is what gates this to the only browser that supports PDF generation. And await browser.close() is the line most tutorials skip; without it, you leak the browser process and your test or job grinds to a halt after enough invocations.
Tip: Want the broader Node.js HTML-to-PDF landscape before you commit to Playwright? Our JavaScript HTML-to-PDF guide covers the full library lineup, including Puppeteer, jsPDF, and the hosted-engine path.
The Chromium-only constraint, named honestly
Playwright supports three browsers for navigation and testing: Chromium, Firefox, and WebKit. The page.pdf() method only works on Chromium pages. Calling it on a Firefox or WebKit page raises an exception with the message "Page.pdf: PDF generation is only supported in Headless Chromium." This is a Playwright API constraint, not a bug, and it's not on the roadmap to change.
When this matters: cross-browser test suites that want to grab a PDF from each browser as part of the test. You can't, because the other two raise the moment you call pdf(). When it doesn't: 95% of HTML-to-PDF use cases want a Chromium-rendered PDF and nothing else. For those, import the chromium export directly and skip the multi-browser dance entirely.
The brand-voice rule for our guides is to tell you what's hard up front. The hard thing here is the gap between "cross-browser" in the marketing and "Chromium-only" in the PDF API. Once you know that, the rest of the API is well-designed.
The full page.pdf() options reference
The page.pdf() call accepts about 15 options. Most tutorials show three. Here's the complete list with defaults and what each one does.
| Option | Type | Default | What it does |
|---|---|---|---|
path |
string | none | File to write the PDF to. Omit to get the buffer back from the function. |
format |
string | Letter |
A4, A3, Legal, Tabloid, plus the rest of the ISO series. |
width / height |
string | none | Custom page size with CSS units ('8.5in'). Overrides format. |
margin |
object | {} |
{ top, right, bottom, left } with CSS units. |
printBackground |
boolean | false |
Set to true for any styled invoice. Background colors and images are off by default. |
displayHeaderFooter |
boolean | false |
Enables headerTemplate and footerTemplate. |
headerTemplate |
string | none | HTML for the page header. Class-based templating (see below). |
footerTemplate |
string | none | HTML for the page footer. Same templating. |
landscape |
boolean | false |
Rotates the page. |
pageRanges |
string | all | Range string like '1-5, 8'. |
scale |
number | 1 |
0.1 to 2. Useful for fitting a wide table on one page. |
preferCSSPageSize |
boolean | false |
Honor @page rules from the CSS. |
outline |
boolean | false |
New in Playwright v1.42. Generate clickable PDF bookmarks from headings. |
tagged |
boolean | false |
Generate a tagged (accessible) PDF. |
The official reference lives in the Playwright page.pdf() docs.
Gotcha: printBackground is off by default
Every styled invoice or report needs printBackground: true. Skip it and your colored headers, brand backgrounds, and shaded table rows render as plain white. It's the single most common reason a Playwright PDF "looks broken" compared to the browser preview.
Gotcha: header and footer templates use a class-based system
headerTemplate and footerTemplate inherit zero styles from the page. You inline every CSS rule, and you reference dynamic data with classes: <span class="date">, <span class="title">, <span class="url">, <span class="pageNumber">, <span class="totalPages">. A working footer with page numbers looks like this:
await page.pdf({
path: 'out.pdf',
format: 'A4',
printBackground: true,
displayHeaderFooter: true,
margin: { top: '20mm', bottom: '20mm', left: '15mm', right: '15mm' },
footerTemplate: `
<div style="font-size:10px; width:100%; text-align:center; color:#666;">
<span class="pageNumber"></span> of <span class="totalPages"></span>
</div>
`,
});
The font-size:10px is inlined because the template won't inherit your page's styles. The width:100%; text-align:center is the standard centering pattern.
Wait for content: networkidle vs waitForSelector
The single decision that breaks or saves a Playwright PDF pipeline is what you wait for before calling pdf(). Two patterns dominate, and one of them is the lazy default that backfires.
The lazy default: waitForLoadState('networkidle')
await page.goto(url);
await page.waitForLoadState('networkidle');
await page.pdf({ path: 'out.pdf', format: 'A4' });
When it works: server-rendered pages and SPAs that finish painting within Playwright's 500-millisecond idle window. When it fails: pages with long-polling websockets, analytics beacons that never settle, or any background traffic that never reaches idle. The wait times out and the PDF either captures a half-painted state or fails outright.
The right answer for client-rendered content: waitForSelector
await page.goto(url);
await page.waitForSelector('.invoice-total', { state: 'visible' });
await page.pdf({ path: 'out.pdf', format: 'A4' });
Wait for the element that signals "done." This is the same pattern hosted engines use. The wait_for option on Transformy's API is the same idea over HTTP.
Marisol runs an in-house invoicing tool at a mid-size SaaS. She shipped the first version with networkidle and it worked for two weeks. Then her team added a chart library to the invoice template, and the chart's async render pushed past the idle window. Suddenly half her PDFs came out blank. The fix was waitForSelector('.chart-rendered') (her component sets that class when the chart's onRender fires), and the issue stopped. She tells the story now to every new dev on her team.
Gotcha: print stylesheets
If your app styles for screen by default and you have @media print rules, add one line before page.pdf():
await page.emulateMedia({ media: 'print' });
This swaps Playwright to honor the print stylesheet. Skip it and you'll get screen styles in the PDF.
Generate a styled invoice PDF in Playwright (complete example)
End to end: launch Chromium, set the HTML, emulate print media, wait for content, render with the full options set.
import { chromium } from 'playwright';
const html = `
<!DOCTYPE html>
<html>
<head>
<style>
body { font-family: sans-serif; padding: 24px; }
h1 { color: #1f6feb; }
table { width: 100%; border-collapse: collapse; }
th, td { border: 1px solid #ddd; padding: 8px; text-align: left; }
.invoice-totals { page-break-inside: avoid; }
</style>
</head>
<body>
<h1>Invoice #1041</h1>
<table>
<tr><th>Item</th><th>Total</th></tr>
<tr><td>Hosting (Jun)</td><td>$49.00</td></tr>
</table>
</body>
</html>
`;
const browser = await chromium.launch();
const page = await browser.newPage();
await page.setContent(html, { waitUntil: 'load' });
await page.emulateMedia({ media: 'print' });
await page.pdf({
path: 'invoice.pdf',
format: 'A4',
printBackground: true,
margin: { top: '20mm', bottom: '20mm', left: '15mm', right: '15mm' },
displayHeaderFooter: true,
headerTemplate: '<div></div>',
footerTemplate: `
<div style="font-size:10px; width:100%; text-align:center; color:#666;">
<span class="pageNumber"></span> of <span class="totalPages"></span>
</div>
`,
});
await browser.close();
Run it and you get an A4 PDF with the brand-color heading, the styled table with backgrounds intact, page numbers in the footer, and 20-millimeter top and bottom margins. The empty headerTemplate is the workaround for "I want a footer but no header"; pass an empty div because Playwright requires both templates when displayHeaderFooter is on.
Playwright vs Puppeteer for PDF generation
For most use cases the two libraries have functional parity. Both run Chromium, both expose a similar pdf() shape, both produce equivalent output. There are three real differences that matter for the PDF use case.
| Dimension | Playwright | Puppeteer |
|---|---|---|
| PDF API maturity | Newer; v1.42 added outline (bookmarks) ahead of Puppeteer |
Older CDP integration; battle-tested in high-volume pipelines |
| Multi-language | Node, Python, .NET, Java parity | Node only (PuppeteerSharp is a community port for. NET) |
| Plugin ecosystem | Modest | Larger, including stealth plugins and PDF tweaks |
| Cross-browser API | Yes (PDF is Chromium-only) | Chrome-only |
| Cold-start footprint | Slightly larger | Slightly smaller |
When Playwright wins
You already use Playwright for tests; PDF is a natural extension of the same toolchain. You want the v1.42 outline option for clickable bookmarks. You want a single API across Node, Python, .NET, or Java because your team shares Playwright across stacks.
When Puppeteer wins
You need maximum maturity for a high-volume PDF pipeline. You're Chrome-only and don't need a cross-browser API surface. You want the broader stealth-and-tweaks plugin ecosystem.
For the Puppeteer-specific walkthrough, see our Puppeteer HTML-to-PDF guide. The migration in either direction is small; the option names map closely.
Run Playwright on AWS Lambda: the production cost
Playwright on Lambda is doable. It's also the moment most teams discover that "auto-scaling Chromium for $0.0000167 per millisecond" comes with a footnote.
The four numbers that matter
- Cold start: 5 to 15 seconds for Chromium-based Lambda functions. Worst-case end-user latency on any cold invocation.
- Lambda layer size: 250 MB unzipped, 50 MB zipped. The Chromium binary alone runs 150 to 300 MB depending on platform, so it doesn't fit. See the AWS Lambda quotas page for the official numbers.
- /tmp: 512 MB default. Heavy PDFs that buffer to disk can fill it. Raise via the ephemeral storage config if needed.
- Memory: budget 1024 to 2048 MB per function for reliable renders. Smaller works for small PDFs but flakes at the tail.
Two real deployment paths
Path 1: container image (recommended). Use Microsoft's Playwright base image, push to ECR, deploy as a Lambda container image. Bypasses the 250 MB layer cap because container images can be up to 10 GB.
FROM mcr.microsoft.com/playwright:v1.49.0-jammy AS base
WORKDIR /var/task
COPY package.json package-lock.json ./
RUN npm install --omit=dev
COPY . .
CMD ["index.handler"]
Path 2: chrome-aws-lambda + playwright-core. Skip the bundled browser binaries that Playwright ships with by default; pull a Lambda-optimized Chromium from chrome-aws-lambda.
import chromium from 'chrome-aws-lambda';
import { chromium as playwright } from 'playwright-core';
export const handler = async (event) => {
const browser = await playwright.launch({
args: chromium.args,
executablePath: await chromium.executablePath,
headless: true,
});
const page = await browser.newPage();
await page.setContent(event.html);
const pdf = await page.pdf({ format: 'A4', printBackground: true });
await browser.close();
return { statusCode: 200, body: pdf.toString('base64'), isBase64Encoded: true };
};
The cold-start mitigation problem
Provisioned concurrency works, but it costs real money: roughly the same per-month as the function it provisions. SnapStart isn't yet supported for Node Chromium runtimes as of mid-2026. If your endpoint has a sub-second SLA, Lambda is the wrong target. Use a long-running container, an ECS service, or a hosted engine instead.
Theo runs the back-end for a small reporting SaaS. He moved invoice PDFs to Lambda for the auto-scaling promise, hit 8-second cold starts every Monday morning when the cache was cold, and added provisioned concurrency to mask it. The provisioned-concurrency bill ended up larger than the Lambda bill it was masking. He moved the PDF endpoint to a hosted engine over a weekend; the wait_for semantics translated one to one, the cold start went away, and the provisioned-concurrency line item disappeared from his monthly invoice.
When to stop running Playwright yourself
The math flips at the point where the operational cost of running a browser farm (cold starts, layer management, memory tuning, on-call for the Chromium process) exceeds the per-render cost of a hosted API. Three signs you've hit that point: you're tuning Lambda memory more than you're shipping product, your provisioned-concurrency bill rivals your Lambda bill, or your team isn't a browser-ops team and doesn't want to become one.
A hosted Chrome engine is the same Chromium under the hood, with the same wait semantics over HTTP. Migrating from a Playwright script to a hosted call is small:
const response = await fetch('https://api.transformy.io/v1/render', {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.TRANSFORMY_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
html,
engine: 'chrome',
wait_for: '.invoice-total',
margin: { top: '20mm', bottom: '20mm' },
}),
});
const { url } = await response.json();
engine: "chrome" keeps Chromium rendering. wait_for is the HTTP equivalent of page.waitForSelector. The handler shrinks from a Playwright + Lambda layer setup with 50 lines of glue to a fetch call with maybe 15.
Tip: Want to compare your existing Playwright output to a hosted Chrome render side by side? Start on the Transformy free tier and post the same HTML to the API. No credit card, no Chromium to install, render takes about as long as a fresh npm install.FAQ: Playwright HTML to PDF
Can Playwright generate PDFs in Firefox or WebKit?
No. The page.pdf() method is Chromium-only. Calling it on a Firefox or WebKit page raises an exception. Playwright's cross-browser support covers navigation and testing, not PDF generation. Use the chromium import directly when your only output is a PDF.
How do I add headers and footers to a Playwright PDF?
Set displayHeaderFooter: true and pass headerTemplate and footerTemplate HTML strings. The templates inherit zero page styles, so inline every CSS rule. Reference dynamic data with the built-in classes: <span class="pageNumber"></span>, <span class="totalPages"></span>, <span class="date"></span>, <span class="title"></span>, <span class="url"></span>. The full footer pattern is in the code above.
Why is my Playwright PDF rendering with no background colors?
printBackground defaults to false. Set it to true in your page.pdf() call. Background colors and images render correctly once the option flips. This is the single most common cause of "the PDF doesn't match my browser preview."
How do I wait for client-side rendering before calling page.pdf()?
Use page.waitForSelector('.your-ready-element') instead of waitForLoadState('networkidle'). The selector wait is reliable on pages with background activity that never goes idle (analytics beacons, long-poll connections, websockets); the idle wait is not. Use the same pattern when the page renders charts or other client-side widgets.
Is Playwright or Puppeteer better for PDF generation?
For most teams, it's a wash. Choose Playwright if you already run it for tests, want the v1.42 outline (bookmarks) option, or share automation code across Node, Python, .NET, or Java. Choose Puppeteer if you need maximum maturity for a high-volume pipeline or want the larger stealth-plugin ecosystem. Output is functionally equivalent; the choice is mostly about which library your team is already in.
How do I run Playwright PDF generation on AWS Lambda?
Two paths. Deploy a container image based on mcr.microsoft.com/playwright (recommended; bypasses the 250 MB layer limit). Or pair playwright-core with chrome-aws-lambda to fit inside the layer cap. Budget 1024 to 2048 MB of function memory and accept that cold starts run 5 to 15 seconds. If sub-second cold response matters, Lambda is the wrong target.
Video walkthrough: (TODO: editor to paste a verified YouTube embed URL here. Recommended search term: "Playwright PDF generation tutorial 2026" or "Playwright AWS Lambda Chromium.")
Conclusion: Playwright is the right call until production bites
Playwright's page.pdf() is Chromium-only, the options are well-designed, and the v1.42 outline for bookmarks is a real win. It's the right HTML-to-PDF choice for teams already running Playwright for tests and willing to budget the Lambda cold-start cost (or run on a long-lived container).
Plan the migration the moment the operational math flips. The three signals: tuning Lambda memory more than shipping product, provisioned-concurrency bills that rival the Lambda bills they mask, or a team that doesn't want to be a browser-ops team. A hosted Chrome engine preserves the wait semantics with one HTTP call.
If you're testing the hosted path, render your first PDF on the Transformy free tier. Point it at the same HTML your Playwright script already produces, see the output, decide later. The broader Node landscape is in our JavaScript HTML-to-PDF pillar guide if you want context on the alternatives.