April 26, 2026

Playwright PDF Too Slow? Cut 80% of Code with a PDF API

PlaywrightmigrationPDF APIDevOpsNode.js

Playwright is a great browser automation tool — but using it for PDF generation in production means managing Chromium lifecycle, browser pools, memory limits, and version compatibility. None of that is your core problem.

This guide covers how to migrate from Playwright to the FUNBREW PDF API, with concrete before/after code in Node.js and Python, cost numbers, and a step-by-step migration checklist.

The Playwright PDF Operations Tax

Playwright inherits the same operational overhead as Puppeteer when used for PDF generation. Here is what that looks like in practice.

Memory Per Browser Context

Each Playwright Chromium launch uses 200–500MB of RAM. Running three concurrent PDF jobs means 600MB–1.5GB just for browser processes, before your application memory. OOM kills become a real risk on memory-constrained servers.

Context Lifecycle Management

Playwright requires you to manage both the browser instance and browser contexts. Leaking a context is easy to do under error conditions. Left unclosed, contexts accumulate and slowly exhaust memory.

// Common mistake: context not closed on error
const context = await browser.newContext();
const page = await context.newPage();
await page.setContent(html); // if this throws, context stays open
const pdf = await page.pdf();
await context.close(); // never reached on error

Chromium Version Pinning

Every npm install of @playwright/test locks you to a specific Chromium build. Updates introduce new rendering behavior — font metrics, CSS @page support, flexbox edge cases — and break your test suite in unpredictable ways. CI/CD pipelines stall when the Playwright Chromium download fails in restricted network environments.

Cold Starts in Serverless

playwright.chromium.launch() takes 1–3 seconds even on warm metal. In Lambda or Cloud Run, cold starts can reach 8–15 seconds. The serverless PDF API guide explains why a managed API eliminates cold starts entirely.

Before / After: Node.js Code Comparison

Before: Playwright (Self-managed)

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

// Browser must be kept alive and shared across requests
let browser;

async function getBrowser() {
  if (!browser || !browser.isConnected()) {
    browser = await chromium.launch({
      args: ['--no-sandbox', '--disable-setuid-sandbox'],
    });
  }
  return browser;
}

async function generatePdfWithPlaywright(html) {
  const browser = await getBrowser();
  const context = await browser.newContext();
  const page    = await context.newPage();

  try {
    await page.setContent(html, { waitUntil: 'networkidle' });
    const pdf = await page.pdf({
      format: 'A4',
      margin: { top: '20mm', bottom: '20mm', left: '15mm', right: '15mm' },
      printBackground: true,
    });
    return pdf;
  } finally {
    await context.close(); // Must be in finally to prevent leaks
  }
}

// Also required:
// - Browser health check and restart logic
// - Concurrency limiter (semaphore or queue)
// - Timeout per-page with AbortController
// - npx playwright install chromium (hundreds of MB, per environment)
// - Chromium version testing on every @playwright/test update

After: Managed API

async function generatePdf(html) {
  const response = await fetch('https://pdf.funbrew.cloud/api/v1/pdf/generate', {
    method: 'POST',
    headers: {
      'X-API-Key': process.env.FUNBREW_PDF_API_KEY,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      html,
      options: {
        format: 'A4',
        margin: { top: '20mm', bottom: '20mm', left: '15mm', right: '15mm' },
        printBackground: true,
      },
    }),
  });

  if (!response.ok) throw new Error(`PDF API error: ${response.status}`);
  return Buffer.from(await response.arrayBuffer());
}

// That is the entire implementation.
// No browser, no context, no pool, no Chromium install.

The Playwright code manages about 40 lines of infrastructure for every PDF endpoint. The API version is 12 lines, reusable everywhere.

Before / After: Python Code Comparison

Before: Playwright (Python async)

import asyncio
from playwright.async_api import async_playwright

async def generate_pdf_playwright(html: str) -> bytes:
    async with async_playwright() as p:
        # Chromium launch adds 1–3 seconds per cold start
        browser = await p.chromium.launch()
        context = await browser.new_context()
        page    = await context.new_page()

        try:
            await page.set_content(html, wait_until="networkidle")
            pdf = await page.pdf(
                format="A4",
                margin={"top": "20mm", "bottom": "20mm",
                        "left": "15mm", "right": "15mm"},
                print_background=True,
            )
        finally:
            await browser.close()

        return pdf

# Requirements:
# pip install playwright
# playwright install chromium   # ~300MB download per environment

After: Managed API (Python)

import os
import requests

def generate_pdf(html: str) -> bytes:
    resp = requests.post(
        "https://pdf.funbrew.cloud/api/v1/pdf/generate",
        headers={
            "X-API-Key": os.environ["FUNBREW_PDF_API_KEY"],
            "Content-Type": "application/json",
        },
        json={
            "html": html,
            "options": {
                "format": "A4",
                "margin": {
                    "top": "20mm", "bottom": "20mm",
                    "left": "15mm", "right": "15mm",
                },
                "printBackground": True,
            },
        },
        timeout=30,
    )
    resp.raise_for_status()
    return resp.content

For async Python applications (FastAPI, Starlette, Django Channels), use httpx:

import os
import httpx

async def generate_pdf_async(html: str) -> bytes:
    async with httpx.AsyncClient(timeout=30.0) as client:
        response = await client.post(
            "https://pdf.funbrew.cloud/api/v1/pdf/generate",
            headers={
                "X-API-Key": os.environ["FUNBREW_PDF_API_KEY"],
                "Content-Type": "application/json",
            },
            json={
                "html": html,
                "options": {
                    "format": "A4",
                    "margin": {
                        "top": "20mm", "bottom": "20mm",
                        "left": "15mm", "right": "15mm",
                    },
                    "printBackground": True,
                },
            },
        )
        response.raise_for_status()
        return response.content

Performance Comparison

Here are representative numbers. Actual results vary by environment and template complexity.

Response Times

Scenario Playwright (Cold start) Playwright (Warm pool) FUNBREW PDF API
Simple HTML (~10KB) 3,000–5,000ms 500–800ms 300–600ms
HTML with images (~100KB) 4,000–8,000ms 800–1,500ms 500–1,200ms
Complex CSS + JS charts 6,000–15,000ms 1,500–3,000ms 800–2,000ms
Lambda cold start 8,000–15,000ms 300–600ms

Cold start includes Chromium launch. A warm pool avoids the launch but still bears the overhead of process management and network idle detection on your server.

Memory Usage

Playwright (3-process pool, Node.js):
  Idle:               600MB–1.5GB
  During generation:  1GB–2GB+
  10 concurrent jobs: 3GB+

FUNBREW PDF API client:
  Idle:               <10MB
  During generation:  <50MB (HTTP response buffering only)
  100 concurrent:     similar — no local Chromium running

Docker Image Size

# Before: Node.js + Playwright + Chromium
FROM node:20
RUN npx playwright install chromium   # ~300MB
# Result: 900MB+

# After: API client only
FROM node:20-alpine
# No Chromium needed
# Result: ~150MB

Smaller images mean faster CI builds, faster deploys, and lower container registry costs. See the Docker/Kubernetes PDF guide for container-specific configuration.

Cost Analysis: Self-Hosted vs Managed API

Self-Hosted Costs

Server

Playwright 3-process pool on AWS t3.medium (2 vCPU / 4GB RAM):
  On-demand:            ~$33/month
  Need 4GB+ for safety: upgrade to t3.large = ~$66/month

Engineering time

Initial implementation (pool + retry + timeout): 8–16 hours
Playwright/Chromium upgrades:                   2–4 hours × 3–4/year
Incident response (OOM, zombie contexts):       2–8 hours × 0–2/month

Estimated annual engineering cost:             20–50 hours

CI/CD overhead

Playwright Chromium downloads add 2–5 minutes to every CI run. Docker images over 900MB push and pull slowly. Registry storage costs compound over time.

FUNBREW PDF API Costs

Free tier:        30 PDFs/month — no cost
Paid plans:       usage-based, see /pricing
Maintenance cost: near zero (HTTP client code only)

Break-Even Summary

Monthly Volume Self-Hosted FUNBREW PDF API Verdict
< 100 PDFs $10–30 server + eng time Free or very low API wins clearly
100–10,000 PDFs $30–100 server + eng hours Usage-based API usually cheaper when hours factored in
10,000+ PDFs Dedicated infra + ops team Scales on API side Depends on specifics

The missing variable in most cost analyses is engineer opportunity cost. Every hour spent on Chromium management is an hour not spent on features that generate revenue.

Migration Steps

Step 1: Validate Output Quality

Paste your existing HTML template into the Playground and compare the PDF output side-by-side with your current Playwright output. The engines are both Chromium-based, so the output is nearly identical in most cases.

Step 2: Install the API Key

# Add to .env
FUNBREW_PDF_API_KEY=your_key_here

Sign up for the free plan (30 PDFs/month) to obtain a key and test before committing.

Step 3: Replace the PDF Generation Function

Create a wrapper function with the same signature as your current implementation:

// utils/pdf.js — drop-in replacement for Playwright-based generatePdf()
export async function generatePdf(html, options = {}) {
  const response = await fetch('https://pdf.funbrew.cloud/api/v1/pdf/generate', {
    method: 'POST',
    headers: {
      'X-API-Key': process.env.FUNBREW_PDF_API_KEY,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      html,
      options: {
        format: 'A4',
        margin: { top: '20mm', bottom: '20mm', left: '15mm', right: '15mm' },
        printBackground: true,
        ...options,
      },
    }),
  });

  if (!response.ok) {
    const body = await response.text();
    throw new Error(`PDF API error ${response.status}: ${body}`);
  }
  return Buffer.from(await response.arrayBuffer());
}

All existing call sites (const pdf = await generatePdf(html)) continue to work unchanged.

Step 4: Review CSS Media Queries

Playwright defaults to the screen media type. The API defaults to print. Check your CSS for @media screen rules and update them:

/* Before: screen-only (ignored by API's print media type) */
@media screen {
  .invoice { padding: 40px; background: #fff; }
}

/* After: apply unconditionally, or target print */
.invoice { padding: 40px; background: #fff; }
/* or */
@media print {
  .invoice { padding: 40px; background: #fff; }
}

Step 5: Convert SSR-dependent Patterns

If you used page.waitForSelector() or page.evaluate() to inject data before export, move that logic server-side:

// Before: Playwright DOM injection
await page.evaluate((data) => {
  document.querySelector('#total').textContent = data.total;
  document.querySelector('#customer').textContent = data.customerName;
}, invoiceData);
const pdf = await page.pdf();

// After: Server-side HTML generation (SSR)
const html = renderInvoiceTemplate(invoiceData); // build complete HTML first
const pdf  = await generatePdf(html);            // then call the API

Step 6: Remove Playwright Dependencies

npm uninstall playwright @playwright/test

# Remove from Dockerfile:
# RUN npx playwright install chromium

# Remove from CI/CD:
# - Playwright Chromium cache steps
# - PLAYWRIGHT_BROWSERS_PATH env var

See the full PDF API production checklist for post-migration hardening.

Common Migration Issues and Fixes

Issue 1: External images not rendering

Cause: The API cannot reach localhost or private network URLs.

Fix: Embed images as Base64 data URIs.

import fs from 'fs';
import path from 'path';

function embedImage(imagePath) {
  const ext  = path.extname(imagePath).slice(1);
  const data = fs.readFileSync(imagePath).toString('base64');
  return `data:image/${ext};base64,${data}`;
}

const html = `<img src="${embedImage('./logo.png')}" alt="Logo">`;

Issue 2: Google Fonts not loading

Cause: Network latency or restricted outbound access causes font requests to time out.

Fix: Either use waitForNetworkIdle: true (included by default) or embed the font as Base64. For Japanese text, FUNBREW PDF includes Noto Sans JP pre-installed — no download needed.

<style>
  /* Option A: Google Fonts URL (works when the API can reach the internet) */
  @import url('https://fonts.googleapis.com/css2?family=Inter&display=swap');

  /* Option B: Always works — embed Base64 font */
  @font-face {
    font-family: 'Inter';
    src: url('data:font/woff2;base64,d09GMgAB...') format('woff2');
  }
  body { font-family: 'Inter', sans-serif; }
</style>

Issue 3: Chart.js renders blank

Cause: The chart animation is still running when the PDF is captured.

Fix: Disable animations and use waitForNetworkIdle: true to ensure CDN assets load.

new Chart(ctx, {
  type: 'bar',
  data: { ... },
  options: {
    animation: false,  // Required for PDF generation
    responsive: true,
  },
});

Pass waitForNetworkIdle: true in the API options if Chart.js is loaded from a CDN. See the report generation guide for a complete Chart.js + PDF pattern.

Issue 4: Timeout errors on complex pages

Fix: Add retry logic with exponential backoff.

async function generatePdfWithRetry(html, options = {}, maxRetries = 3) {
  for (let attempt = 1; attempt <= maxRetries; attempt++) {
    try {
      const controller = new AbortController();
      const timeoutId  = setTimeout(() => controller.abort(), 30_000);

      const response = await fetch('https://pdf.funbrew.cloud/api/v1/pdf/generate', {
        method: 'POST',
        headers: {
          'X-API-Key': process.env.FUNBREW_PDF_API_KEY,
          'Content-Type': 'application/json',
        },
        body: JSON.stringify({ html, options }),
        signal: controller.signal,
      });

      clearTimeout(timeoutId);

      if (response.status === 429 && attempt < maxRetries) {
        await new Promise(r => setTimeout(r, 2 ** attempt * 1000));
        continue;
      }

      if (!response.ok) throw new Error(`API error: ${response.status}`);
      return Buffer.from(await response.arrayBuffer());
    } catch (err) {
      if (attempt === maxRetries) throw err;
      await new Promise(r => setTimeout(r, 1000 * attempt));
    }
  }
}

See the PDF API error handling guide for the complete retry and alerting pattern.

Migration Checklist

Pre-migration
  [ ] Paste HTML into the Playground — compare output with Playwright
  [ ] Sign up for free plan, obtain FUNBREW_PDF_API_KEY
  [ ] Create wrapper function generatePdf(html, options)
  [ ] Visual diff the PDF output in staging

CSS review
  [ ] Audit @media screen vs @media print rules
  [ ] Verify page-break-before / break-before: page behaviour
  [ ] Convert external fonts to @font-face Base64 if needed
  [ ] Confirm background colours render with printBackground: true

Code review
  [ ] Convert page.waitForSelector() calls to SSR
  [ ] Convert page.evaluate() DOM injections to template rendering
  [ ] Verify local image paths are converted to Base64

Production cutover
  [ ] Add error handling (retry on 429, alert on 5xx)
  [ ] Set 30-second client timeout
  [ ] Run: npm uninstall playwright @playwright/test
  [ ] Remove npx playwright install from Dockerfile
  [ ] Remove Playwright cache from CI/CD
  [ ] Right-size server memory (Chromium no longer required)

What Improves After Migration

Aspect Playwright (Self-managed) FUNBREW PDF API
Memory usage 200–500MB per context Near zero (HTTP client only)
Cold start 1–3 seconds None
Concurrency Manual context pooling Auto-scaling on API side
Chromium version management Required on every update Not needed
Serverless (Lambda/Cloud Run) Layers and workarounds Works out of the box
Docker image size 900MB+ ~150MB
Lambda cold start 8–15 seconds 300–600ms
CI build time +2–5 min (Chromium download) No change

Try It Before Committing

Paste your Playwright HTML template into the Playground and see the PDF output instantly — no signup required. When you are ready to test with code, the free plan (30 PDFs/month) gives you API access to validate against your staging environment.

For the full API reference, see the documentation.

Related

Powered by FUNBREW PDF