Skip to content
ExponaBLOG
PLAYWRIGHT & BROWSER AUTOMATION · DAY 064 / 100

Generating PDFs with Playwright: lessons from production

Pixel-perfect PDFs from HTML.

Expona Admin9 min read

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():

typescript
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:

typescript
await page.setContent(htmlContent);
await page.waitForLoadState('networkidle');
const pdfBuffer = await page.pdf({ format: 'A4' });

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:

typescript
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:

typescript
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:

typescript
<!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:

typescript
// 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;
}
typescript
// 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:

typescript
// 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 after setContent() may render blank PDFs. Always await waitForLoadState('networkidle') or wait for specific selectors with waitForSelector().
  • 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 print or 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.png and 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.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles