Playwright PDF Too Slow? Cut 80% of Code with a PDF API
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
- Puppeteer to PDF API Migration — Same migration path, Puppeteer-specific context
- PDF Report Generation Guide — Chart.js, cron scheduling, email delivery
- PDF API Quickstart by Language — Node.js, Python, PHP code examples
- HTML to PDF CSS Tips — CSS compatibility and print media type tips
- PDF API Serverless Guide — Lambda and Cloud Run deployment
- PDF API Error Handling Guide — Retry logic and alerting patterns
- PDF API Production Checklist — Post-migration hardening
- PDF API Docker/Kubernetes Guide — Container-based deployment
- API Documentation — Full endpoint reference