April 19, 2026

Playwright PDF Generation: page.pdf() Guide & API Migration

PlaywrightPDF generationNode.jstutorialautomation

Playwright is widely used for browser automation and end-to-end testing — but its page.pdf() method also makes it a capable HTML-to-PDF converter. This guide covers everything from basic setup to production challenges, with complete code examples.

If you're migrating from Puppeteer, the approach is nearly identical — see the Puppeteer to PDF API Migration Guide for a side-by-side comparison.

Installation and Setup

Install Playwright

npm install playwright
# Install the Chromium browser (PDF generation is Chromium-only)
npx playwright install chromium

Basic PDF Generation

const { chromium } = require('playwright');

async function generatePdf(htmlContent) {
  const browser = await chromium.launch({ headless: true });
  const page = await browser.newPage();

  // Set HTML content and wait for fonts/images to load
  await page.setContent(htmlContent, { waitUntil: 'networkidle' });

  const pdfBuffer = await page.pdf({
    format: 'A4',
    printBackground: true,
    margin: {
      top: '20mm',
      bottom: '20mm',
      left: '15mm',
      right: '15mm',
    },
  });

  await browser.close();
  return pdfBuffer;
}

// Usage
const html = `
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <style>
    body { font-family: Inter, sans-serif; font-size: 14px; }
    h1 { color: #1a56db; }
  </style>
</head>
<body>
  <h1>Invoice</h1>
  <p>Generated with Playwright.</p>
</body>
</html>`;

generatePdf(html).then(buf => {
  require('fs').writeFileSync('output.pdf', buf);
  console.log('PDF generated');
});

Generate PDF from a URL

const { chromium } = require('playwright');

async function generatePdfFromUrl(url) {
  const browser = await chromium.launch({ headless: true });
  const page = await browser.newPage();

  // Navigate and wait for all network requests to settle
  await page.goto(url, { waitUntil: 'networkidle', timeout: 60000 });

  const pdfBuffer = await page.pdf({
    format: 'A4',
    printBackground: true,
  });

  await browser.close();
  return pdfBuffer;
}

page.pdf() Options Reference

Paper Size and Orientation

const pdf = await page.pdf({
  // Named format (takes priority over width/height)
  format: 'A4',   // A4, A3, Letter, Legal, Tabloid, etc.

  // Custom dimensions (used only if format is not set)
  width: '210mm',
  height: '297mm',

  // Landscape orientation
  landscape: true,

  // Rendering scale factor (0.1 to 2, default: 1)
  scale: 1.0,
});

Margins

const pdf = await page.pdf({
  margin: {
    top: '20mm',
    bottom: '20mm',
    left: '15mm',
    right: '15mm',
  },
});

Background Colors and Images

Browsers suppress CSS backgrounds in print by default. Enable printBackground to include them:

const pdf = await page.pdf({
  printBackground: true,  // Include CSS background-color, background-image
});

Headers and Footers

const pdf = await page.pdf({
  displayHeaderFooter: true,
  headerTemplate: `
    <div style="font-size: 10px; width: 100%; text-align: center; color: #999;">
      <span class="title"></span>
    </div>
  `,
  footerTemplate: `
    <div style="font-size: 10px; width: 100%; text-align: center; color: #999;">
      Page <span class="pageNumber"></span> of <span class="totalPages"></span>
    </div>
  `,
  // Ensure margins are large enough for header/footer
  margin: { top: '40px', bottom: '40px', left: '20px', right: '20px' },
});

Available template classes:

Class Inserts
pageNumber Current page number
totalPages Total page count
date Current date
title Page <title> value
url Page URL

Tagged PDF and Outline (Playwright v1.42+)

const pdf = await page.pdf({
  // Accessible tagged PDF (required for PDF/UA compliance)
  tagged: true,

  // Embed clickable bookmarks from heading structure
  outline: true,

  // Specific page range
  pageRanges: '1-3, 5',
});

CSS Print Media Queries

Playwright applies @media print styles by default. Use print-specific CSS to control the PDF layout:

/* Screen layout */
.sidebar { display: block; width: 250px; }
.print-only { display: none; }

/* PDF / print layout */
@media print {
  .sidebar { display: none; }
  .print-only { display: block; }

  /* Explicit page breaks */
  .page-break {
    break-before: page;
    page-break-before: always; /* fallback */
  }

  /* Prevent breaks inside sections */
  .invoice-section {
    break-inside: avoid;
    page-break-inside: avoid;
  }

  /* Keep headings with the following content */
  h1, h2, h3 {
    break-after: avoid;
    page-break-after: avoid;
  }

  /* Prevent orphaned/widowed lines */
  p {
    orphans: 3;
    widows: 3;
  }

  /* Paper size and margins */
  @page {
    size: A4;
    margin: 20mm 15mm;
  }

  @page :first {
    margin-top: 30mm; /* Extra top margin on first page */
  }
}

Using Screen Media Instead of Print

If you want CSS @media screen styles to apply (not print styles):

await page.emulateMedia({ media: 'screen' });
const pdf = await page.pdf({ printBackground: true });

Complete Example: Japanese Invoice PDF

const { chromium } = require('playwright');

const invoiceData = {
  invoiceNumber: 'INV-2026-0042',
  issueDate: 'April 19, 2026',
  dueDate: 'April 30, 2026',
  clientName: 'Acme Corporation',
  items: [
    { name: 'Web Application Development', qty: 1, unitPrice: 5000 },
    { name: 'UI/UX Design', qty: 1, unitPrice: 1500 },
  ],
};

function buildInvoiceHtml(data) {
  const subtotal = data.items.reduce((s, i) => s + i.qty * i.unitPrice, 0);
  const tax = Math.round(subtotal * 0.1);
  const total = subtotal + tax;

  const rows = data.items
    .map(item => `
      <tr>
        <td>${item.name}</td>
        <td style="text-align:right;">${item.qty}</td>
        <td style="text-align:right;">$${item.unitPrice.toLocaleString()}</td>
        <td style="text-align:right;">$${(item.qty * item.unitPrice).toLocaleString()}</td>
      </tr>`)
    .join('');

  return `<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <style>
    * { box-sizing: border-box; margin: 0; padding: 0; }
    body { font-family: Inter, Arial, sans-serif; font-size: 13px; color: #1a1a1a; }
    @page { size: A4; margin: 15mm 20mm; }
    .page { max-width: 210mm; padding: 20px; }
    .header {
      display: flex; justify-content: space-between;
      margin-bottom: 32px; border-bottom: 3px solid #1a56db; padding-bottom: 16px;
    }
    .title { font-size: 32px; font-weight: 700; color: #1a56db; }
    table { width: 100%; border-collapse: collapse; margin-top: 24px; }
    th { background: #1a56db; color: #fff; padding: 10px 12px; text-align: left; }
    td { padding: 10px 12px; border-bottom: 1px solid #e5e7eb; }
    td:last-child { text-align: right; }
    .grand-total { font-size: 20px; font-weight: 700; color: #1a56db; }
  </style>
</head>
<body>
  <div class="page">
    <div class="header">
      <div class="title">INVOICE</div>
      <div style="text-align:right; color:#6b7280; font-size:12px;">
        <strong>${data.invoiceNumber}</strong><br>
        Issued: ${data.issueDate}<br>
        Due: ${data.dueDate}
      </div>
    </div>
    <p><strong>Bill To: ${data.clientName}</strong></p>
    <table>
      <thead>
        <tr><th>Description</th><th>Qty</th><th>Unit Price</th><th>Amount</th></tr>
      </thead>
      <tbody>${rows}</tbody>
    </table>
    <p style="text-align:right; margin-top:16px;">Subtotal: $${subtotal.toLocaleString()}</p>
    <p style="text-align:right;">Tax (10%): $${tax.toLocaleString()}</p>
    <p class="grand-total" style="text-align:right; margin-top:8px;">Total: $${total.toLocaleString()}</p>
  </div>
</body>
</html>`;
}

async function main() {
  const browser = await chromium.launch({ headless: true });
  const page = await browser.newPage();

  await page.setContent(buildInvoiceHtml(invoiceData), { waitUntil: 'networkidle' });

  const buf = await page.pdf({
    format: 'A4',
    printBackground: true,
    margin: { top: '15mm', bottom: '20mm', left: '20mm', right: '20mm' },
    displayHeaderFooter: true,
    footerTemplate: `
      <div style="font-size:9px; width:100%; text-align:center; color:#9ca3af;">
        Generated by Playwright — Page <span class="pageNumber"></span> of <span class="totalPages"></span>
      </div>`,
  });

  require('fs').writeFileSync('invoice.pdf', buf);
  await browser.close();
  console.log('invoice.pdf created');
}

main();

TypeScript Version

import { chromium, Page } from 'playwright';

interface PdfOptions {
  format?: 'A4' | 'A3' | 'Letter' | 'Legal';
  landscape?: boolean;
  printBackground?: boolean;
}

async function generatePdf(html: string, options: PdfOptions = {}): Promise<Buffer> {
  const browser = await chromium.launch({ headless: true });
  const page: Page = await browser.newPage();

  await page.setContent(html, { waitUntil: 'networkidle' });

  const pdfBuffer = await page.pdf({
    format: options.format ?? 'A4',
    landscape: options.landscape ?? false,
    printBackground: options.printBackground ?? true,
    margin: { top: '20mm', bottom: '20mm', left: '15mm', right: '15mm' },
  });

  await browser.close();
  return pdfBuffer;
}

export { generatePdf };

Production Challenges

Playwright works well for low-volume or one-off PDF generation, but production workloads surface several challenges.

Memory Consumption

Each Chromium instance consumes 150–300MB of memory. Under concurrent load, memory usage scales linearly — leading to OOM crashes.

// Browser pool to limit concurrent Chromium instances
const { chromium } = require('playwright');

class BrowserPool {
  constructor(maxSize = 3) {
    this.maxSize = maxSize;
    this.active = 0;
    this.queue = [];
  }

  async acquire() {
    if (this.active < this.maxSize) {
      this.active++;
      return chromium.launch({ headless: true });
    }
    return new Promise((resolve) => this.queue.push(resolve));
  }

  async release(browser) {
    await browser.close();
    this.active--;
    if (this.queue.length > 0) {
      const next = this.queue.shift();
      this.active++;
      const newBrowser = await chromium.launch({ headless: true });
      next(newBrowser);
    }
  }
}

const pool = new BrowserPool(3);

async function generatePdfPooled(html) {
  const browser = await pool.acquire();
  try {
    const page = await browser.newPage();
    await page.setContent(html, { waitUntil: 'networkidle' });
    const buf = await page.pdf({ format: 'A4', printBackground: true });
    await page.close();
    return buf;
  } finally {
    await pool.release(browser);
  }
}

Cold Start Latency

Chromium takes 1–3 seconds to start. If you launch and close a browser per request, every PDF request will have that latency. The common workaround — keeping a browser alive — introduces memory leak risk over time.

Serverless Environments

Environment Max Memory Cold Start Chromium Support
AWS Lambda 10 GB 1–3 s Requires @sparticuz/chromium
Google Cloud Run 32 GB ~100 ms Works with standard Docker image
Vercel Functions 3 GB 1–2 s Limited — use @sparticuz/chromium
Fly.io Configurable ~50 ms Full Docker support

AWS Lambda setup:

// Install: npm install @sparticuz/chromium playwright-core
const chromium = require('@sparticuz/chromium');
const { chromium: playwright } = require('playwright-core');

exports.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, { waitUntil: 'networkidle' });
  const buf = await page.pdf({ format: 'A4', printBackground: true });
  await browser.close();

  return {
    statusCode: 200,
    headers: { 'Content-Type': 'application/pdf' },
    body: buf.toString('base64'),
    isBase64Encoded: true,
  };
};

Japanese and CJK Fonts on Linux

Linux servers don't ship with Japanese fonts. Without them, Japanese characters appear as empty boxes. Fix with a Dockerfile:

FROM node:20-slim

RUN apt-get update && apt-get install -y \
  chromium \
  fonts-noto-cjk \
  && rm -rf /var/lib/apt/lists/*

ENV PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1
ENV PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH=/usr/bin/chromium

WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .

CMD ["node", "server.js"]

Migrating from Playwright to FUNBREW PDF API

When your PDF generation volume grows — or when managing Chromium infrastructure becomes a drag — migrating to FUNBREW PDF API lets you keep your existing HTML templates while offloading all browser management.

Why Migrate?

Challenge Playwright Self-Hosted FUNBREW PDF API
Memory management Manual browser pooling Auto-scaled by API
Cold start 1–3 s per new browser Sub-second (managed)
Japanese fonts Manual Docker setup Noto Sans JP pre-installed
Monitoring Build from scratch Built-in metrics & webhooks
Infrastructure cost Server always-on Pay per document
Maintenance Playwright/Chromium version pinning None

Migration Code

Swap only the PDF generation function. Keep all HTML-building logic unchanged:

// Before: Playwright
const { chromium } = require('playwright');

async function generatePdf(html) {
  const browser = await chromium.launch({ headless: true });
  const page = await browser.newPage();
  await page.setContent(html, { waitUntil: 'networkidle' });
  const buf = await page.pdf({
    format: 'A4',
    printBackground: true,
    margin: { top: '20mm', bottom: '20mm', left: '15mm', right: '15mm' },
  });
  await browser.close();
  return buf;
}

// After: FUNBREW PDF API — HTML template code stays the same
async function generatePdf(html) {
  const response = await fetch('https://pdf.funbrew.cloud/api/pdf/generate', {
    method: 'POST',
    headers: {
      'Authorization': `Bearer ${process.env.FUNBREW_PDF_API_KEY}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      html,
      options: {
        engine: 'quality',   // Chromium-based, same rendering as Playwright
        format: 'A4',
        printBackground: true,
        margin: { top: '20mm', bottom: '20mm', left: '15mm', right: '15mm' },
      },
    }),
  });

  const result = await response.json();
  return result.data.download_url;  // Returns a PDF URL instead of a buffer
}

The FUNBREW PDF quality engine uses Chromium — the same rendering engine as Playwright. CSS @media print rules, @page declarations, break-inside: avoid, and all other print CSS work identically.

To receive the PDF as a binary buffer instead of a URL (for streaming to the client directly), check the API documentation for the responseFormat: 'buffer' option.

Summary

  • page.pdf() options: format, printBackground, landscape, displayHeaderFooter, margin, outline, tagged
  • @media print and @page give you full layout control in the PDF output
  • Production challenges: memory (150–300MB per Chromium), cold start (1–3s), serverless binary issues, and missing CJK fonts on Linux
  • Migration path: replace the PDF generation function with a FUNBREW PDF API call — all HTML/CSS templates are directly reusable

Start with the Playground to test your HTML output, then explore the API docs for production configuration options.

Related Guides

Powered by FUNBREW PDF