We've all been there: a customer needs an invoice at 2 AM, or your reporting dashboard needs to pump out hundreds of PDFs before the monthly close. Generating PDFs with Playwright: lessons from production covers what we've learned building reliable PDF generation at scale, where Chromium rendering meets real-world constraints. This guide walks through the mechanics, the gotchas, and the patterns that keep our systems humming.
page.pdf() — Chromium renders HTML to PDF
Playwright's page.pdf() method lets Chromium do what it does best: render a webpage and output it as PDF. Unlike server-side templating engines or headless conversion tools, you get a real browser engine, which means CSS support is excellent and layouts are predictable.
The basic flow is straightforward. Navigate to an HTML page (or set the content directly), then call page.pdf():
import { chromium } from 'playwright';
const browser = await chromium.launch();
const page = await browser.newPage();
await page.setContent(`
<html>
<head></head>
<body>
<h1>Invoice #12345</h1>
<p>Total: $999.99</p>
</body>
</html>
`);
const pdfBuffer = await page.pdf({ format: 'A4' });
await fs.promises.writeFile('invoice.pdf', pdfBuffer);
await browser.close();
The method returns a buffer, which you can write to disk, stream to a client, or store in blob storage. Key options include format (A4, Letter, Legal), width and height for custom sizes, margin to control whitespace, and scale to adjust the rendering scale.
One production gotcha: Chromium needs time to finish rendering. If you call page.pdf() immediately after setting content, images and fonts may not load. Always wait for network idle or specific selectors:
await page.setContent(htmlContent);
await page.waitForLoadState('networkidle');
const pdfBuffer = await page.pdf({ format: 'A4' });
Print CSS — @page rules and page breaks
PDF generation is essentially printing to a file, so print CSS is your best friend. The @page CSS rule lets you define page margins, size, and orientation globally. Page breaks give you control over where content splits across pages.
A typical invoice template uses @page to set consistent margins and page-break-after or page-break-before to keep sections together:
const htmlTemplate = `
<!DOCTYPE html>
<html>
<head>
<style>
@page {
size: A4;
margin: 20mm;
}
@media print {
body {
font-family: Arial, sans-serif;
line-height: 1.6;
color: #333;
}
.invoice-header {
page-break-after: avoid;
margin-bottom: 30px;
}
.invoice-items {
page-break-after: auto;
}
.page-break {
page-break-before: always;
}
table {
width: 100%;
border-collapse: collapse;
}
td, th {
border: 1px solid #ddd;
padding: 8px;
text-align: left;
}
}
</style>
</head>
<body>
<div class="invoice-header">
<h1>Invoice</h1>
<p>From: Your Company</p>
<p>To: Customer Name</p>
</div>
<table>
<thead>
<tr><th>Item</th><th>Qty</th><th>Price</th></tr>
</thead>
<tbody>
<tr><td>Widget</td><td>10</td><td>$50</td></tr>
</tbody>
</table>
<p>Total: $500</p>
</body>
</html>
`;
Wrap everything in @media print to ensure these styles only apply during PDF generation, not when viewing in a browser. Use page-break-after: avoid to keep headers and summaries from splitting mid-section, and page-break-before: always to force a new page when needed.
Testing print CSS can be tedious. Save the HTML to a file, open it in a browser, and use the print preview (Ctrl+P or Cmd+P) to validate the layout before running your Playwright script. This catches ninety percent of rendering issues without spinning up headless browsers.
Templates — Render invoices from HTML templates
Hardcoding HTML strings is messy. In production, we use template engines like Handlebars, EJS, or Nunjucks to separate content from markup. This keeps your code clean and makes updates easier.
Here's a practical example using Handlebars:
import Handlebars from 'handlebars';
import fs from 'fs';
import { chromium } from 'playwright';
// Define the template
const templatePath = './templates/invoice.hbs';
const templateContent = fs.readFileSync(templatePath, 'utf-8');
const template = Handlebars.compile(templateContent);
// Prepare data
const invoiceData = {
invoiceNumber: '12345',
date: new Date().toISOString().split('T')[0],
customerName: 'Acme Corp',
items: [
{ description: 'Consulting', qty: 10, unitPrice: 150 },
{ description: 'Support', qty: 5, unitPrice: 100 },
],
total: 2000,
};
// Render HTML
const htmlContent = template(invoiceData);
// Generate PDF
const browser = await chromium.launch();
const page = await browser.newPage();
await page.setContent(htmlContent);
await page.waitForLoadState('networkidle');
const pdfBuffer = await page.pdf({
format: 'A4',
margin: { top: '20mm', right: '20mm', bottom: '20mm', left: '20mm' },
});
fs.writeFileSync(`invoice-${invoiceData.invoiceNumber}.pdf`, pdfBuffer);
await browser.close();
The template file (invoice.hbs) contains your HTML with placeholders:
<!DOCTYPE html>
<html>
<head>
<style>@media print { body { font-family: Arial; } }</style>
</head>
<body>
<h1>Invoice #{{invoiceNumber}}</h1>
<p>Date: {{date}}</p>
<p>Bill To: {{customerName}}</p>
<table>
<thead><tr><th>Description</th><th>Qty</th><th>Unit Price</th><th>Total</th></tr></thead>
<tbody>
{{#each items}}
<tr>
<td>{{this.description}}</td>
<td>{{this.qty}}</td>
<td>${{this.unitPrice}}</td>
<td>${{multiply this.qty this.unitPrice}}</td>
</tr>
{{/each}}
</tbody>
</table>
<p><strong>Total: ${{total}}</strong></p>
</body>
</html>
Separating template from logic makes it easy for designers to update layouts without touching code. If you need dynamic images (logos, QR codes), inject them as base64-encoded data URIs in the template data rather than loading from URLs, which avoids network delays.
Run in a queue — Generate PDFs in background workers
The danger zone: if you generate PDFs synchronously in your HTTP handler, a spike in requests will crush your server. Long-running Chromium processes pile up, memory leaks emerge, and timeouts multiply. The solution is a background worker queue.
A typical architecture uses Bull (or similar) to queue jobs and a worker process to consume them:
// queue.ts - Producer
import Bull from 'bull';
const pdfQueue = new Bull('pdf-generation', {
redis: { host: 'localhost', port: 6379 },
});
export async function queuePdfGeneration(invoiceData) {
const job = await pdfQueue.add(invoiceData, {
attempts: 3,
backoff: { type: 'exponential', delay: 2000 },
removeOnComplete: true,
});
return job.id;
}
// worker.ts - Consumer
import Bull from 'bull';
import { chromium } from 'playwright';
import Handlebars from 'handlebars';
import fs from 'fs';
import s3Client from './s3';
const pdfQueue = new Bull('pdf-generation', {
redis: { host: 'localhost', port: 6379 },
});
pdfQueue.process(async (job) => {
const { invoiceData, jobId } = job.data;
console.log(`Generating PDF for job ${jobId}`);
const template = Handlebars.compile(
fs.readFileSync('./templates/invoice.hbs', 'utf-8')
);
const htmlContent = template(invoiceData);
const browser = await chromium.launch({ headless: true });
const page = await browser.newPage();
await page.setContent(htmlContent);
await page.waitForLoadState('networkidle');
const pdfBuffer = await page.pdf({
format: 'A4',
margin: { top: '20mm', right: '20mm', bottom: '20mm', left: '20mm' },
});
await browser.close();
// Store in S3 or your blob storage
const key = `invoices/${invoiceData.invoiceNumber}.pdf`;
await s3Client.putObject({
Bucket: 'my-pdfs',
Key: key,
Body: pdfBuffer,
ContentType: 'application/pdf',
}).promise();
console.log(`PDF stored at s3://my-pdfs/${key}`);
return { url: `https://my-pdfs.s3.amazonaws.com/${key}` };
});
pdfQueue.on('completed', (job, result) => {
console.log(`Job ${job.id} completed. PDF: ${result.url}`);
});
pdfQueue.on('failed', (job, err) => {
console.error(`Job ${job.id} failed:`, err.message);
});
From your API, queue the job and return immediately:
// api.ts
import { queuePdfGeneration } from './queue';
app.post('/invoices/:id/generate-pdf', async (req, res) => {
const invoiceId = req.params.id;
const invoice = await db.invoices.findById(invoiceId);
const jobId = await queuePdfGeneration({
invoiceData: invoice,
jobId: invoiceId,
});
res.json({ jobId, status: 'queued' });
});
app.get('/jobs/:jobId/status', async (req, res) => {
const job = await pdfQueue.getJob(req.params.jobId);
if (!job) return res.status(404).json({ error: 'Job not found' });
const state = await job.getState();
const progress = job.progress();
res.json({ state, progress });
});
Key benefits: your API stays responsive, Chromium processes are isolated in worker containers, and failed jobs retry automatically. You can scale workers independently and monitor resource usage per process.
One production lesson: always run Chromium with resource limits. Set --disable-dev-shm-usage in the launch args if you're on Linux with low /dev/shm space, and consider using --single-process only in dev (it crashes more often but uses less memory).
Common mistakes
- Not waiting for resources to load. Calling
page.pdf()immediately aftersetContent()may render blank PDFs. Always awaitwaitForLoadState('networkidle')or wait for specific selectors withwaitForSelector(). - Forgetting print CSS. Screen CSS and print CSS are different. Fonts may not embed, colors may not render, and layouts may break. Wrap styles in
@media printor use a print stylesheet. - Generating PDFs in the request handler. Synchronous PDF generation blocks the thread. Use a queue to keep your API responsive and isolate Chromium crashes.
- Not managing Chromium processes. Leaking browser instances will exhaust memory. Always call
browser.close()in a finally block or after the operation completes. - Using absolute URLs for images without a base URL. If your HTML references
/images/logo.pngand you're rendering offline HTML, the image won't load. Embed images as base64 or pass a baseURL context. - Ignoring timezone and locale issues. Dates and numbers may render differently depending on the system. Format them explicitly in your template data.
Key takeaways
page.pdf()leverages Chromium's rendering engine for pixel-perfect PDF output from HTML. Always wait for resources to load before rendering.- Print CSS (
@page,@media print,page-break-*) controls layout, margins, and page breaks. Test in the browser print preview before deploying. - Use template engines like Handlebars to separate HTML markup from data. Inject images as base64 URIs to avoid network delays.
- Queue PDF generation in background workers (Bull, RQ, Celery) to keep your API responsive and manage resource usage. Always wrap browser operations in proper cleanup.
- Monitor worker health, set retry policies, and log failures. PDF generation is I/O-heavy and benefits from rate-limiting and resource isolation.






Comments
Loading comments…