Puppeteer vs Playwright vs HTML-to-PDF API: The Real Cost of PDF Generation in 2026

Summary. This post compares three ways to turn HTML into a PDF: Puppeteer, Playwright, and a hosted HTML-to-PDF API (Api2Pdf). You get working code for each, a monthly cost model at 10k, 100k, 1M and 10M PDFs built from public AWS and Api2Pdf price pages, and a list of the operational work that never shows up on an AWS bill. Every number is labeled as a list price or an assumption, so you can redo the math with your own workload.

Short version: on raw compute, a well-tuned AWS Lambda function running Chromium is cheaper per render than any API. The gap is small in dollars at low and medium volume and large at very high volume. What decides it is how many engineering hours per month your own renderer costs you.

The same PDF, three ways

All three examples render the same invoice HTML to an A4 PDF with backgrounds and 0.4in margins. Both Puppeteer and Playwright drive Chromium through the DevTools protocol and end up calling the same print-to-PDF code, so output is close to identical for the same Chromium version. Api2Pdf runs Headless Chrome (currently v152) behind POST /chrome/pdf/html.

// html.mjs: shared by both local examples
export const html = `<!doctype html>
<html>
<head>
  <meta charset="utf-8">
  <style>
    body { font-family: system-ui, sans-serif; margin: 0; color: #111; }
    h1 { font-size: 22px; margin: 0 0 12px; }
    table { width: 100%; border-collapse: collapse; }
    td, th { padding: 6px 0; border-bottom: 1px solid #e5e7eb; text-align: left; }
  </style>
</head>
<body>
  <h1>Invoice #1042</h1>
  <table>
    <tr><th>Item</th><th>Amount</th></tr>
    <tr><td>API usage, September</td><td>$120.00</td></tr>
  </table>
</body>
</html>`;

Puppeteer

npm install puppeteer
// pdf-puppeteer.mjs
import puppeteer from "puppeteer";
import { writeFile } from "node:fs/promises";
import { html } from "./html.mjs";

const browser = await puppeteer.launch();
try {
  const page = await browser.newPage();
  await page.setContent(html, { waitUntil: "networkidle0" });
  const pdf = await page.pdf({
    format: "A4",
    printBackground: true,
    margin: { top: "0.4in", right: "0.4in", bottom: "0.4in", left: "0.4in" },
  });
  await writeFile("invoice-puppeteer.pdf", pdf);
} finally {
  await browser.close(); // always close, or Chromium processes pile up
}

Playwright

npm install playwright
npx playwright install chromium
// pdf-playwright.mjs
import { chromium } from "playwright";
import { html } from "./html.mjs";

const browser = await chromium.launch();
try {
  const page = await browser.newPage();
  await page.setContent(html, { waitUntil: "networkidle" });
  await page.pdf({
    path: "invoice-playwright.pdf",
    format: "A4",
    printBackground: true,
    margin: { top: "0.4in", right: "0.4in", bottom: "0.4in", left: "0.4in" },
  });
} finally {
  await browser.close();
}

Two practical differences: Playwright's page.pdf() works only with Chromium (not Firefox or WebKit), and Playwright's waitUntil value is "networkidle" where Puppeteer uses "networkidle0" / "networkidle2". For PDF generation alone, the two libraries are close to interchangeable, and teams usually pick whichever one they already use for tests.

Api2Pdf (Node, Python, curl)

npm install api2pdf
// pdf-api2pdf.mjs
import Api2Pdf from "api2pdf";
import { html } from "./html.mjs";

const client = new Api2Pdf(process.env.API2PDF_API_KEY);

try {
  const result = await client.chromeHtmlToPdf(html, {
    filename: "invoice-1042.pdf",
    options: {
      printBackground: true,
      marginTop: "0.4in",
      marginBottom: "0.4in",
      marginLeft: "0.4in",
      marginRight: "0.4in",
    },
  });
  console.log(result.FileUrl, result.Cost, result.Seconds);
} catch (err) {
  // the client rejects with the API's JSON, e.g. { Success: false, Error: "Invalid API key" }
  console.error(err.Error ?? err);
}
pip install api2pdf
import os
from api2pdf import Api2Pdf

client = Api2Pdf(os.environ["API2PDF_API_KEY"])
html = open("invoice.html").read()

response = client.Chrome.html_to_pdf(
    html,
    file_name="invoice-1042.pdf",
    PrintBackground=True,
    MarginTop="0.4in",
    MarginBottom="0.4in",
)
data = response.result
if data["Success"]:
    print(data["FileUrl"], data["Cost"], data["Seconds"])
    response.save_file("invoice-1042.pdf")  # downloads the hosted file
else:
    print(data["Error"])
curl -X POST "https://v2.api2pdf.com/chrome/pdf/html" \
  -H "Authorization: YOUR-API-KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "html": "<h1>Invoice #1042</h1>",
    "fileName": "invoice-1042.pdf",
    "inline": false,
    "options": { "printBackground": true, "marginTop": "0.4in" }
  }'

The JSON response includes Success, FileUrl (hosted for 24 hours), Cost in USD, Seconds, MbOut and ResponseId. Add ?outputBinary=true to get the PDF bytes back directly, or set useCustomStorage with a presigned PUT URL to write the file straight into your own bucket. Paper size is set with width and height (default 8.27in x 11.69in, which is A4); the full option list is on the Headless Chrome advanced options page.

What production self-hosting actually looks like

The local scripts above are not the production cost. In production you need Chromium in a runtime that scales, a place to put the output, and a way to keep it all patched. The most common serverless setup is AWS Lambda with @sparticuz/chromium and puppeteer-core. A minimal handler that reuses the browser across warm invocations and writes to S3:

// handler.mjs (Lambda, Node.js runtime)
import chromium from "@sparticuz/chromium";
import puppeteer from "puppeteer-core";
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
import { randomUUID } from "node:crypto";

const s3 = new S3Client({});
let browser; // survives between warm invocations

async function getBrowser() {
  if (browser?.connected) return browser;
  browser = await puppeteer.launch({
    args: await puppeteer.defaultArgs({ args: chromium.args, headless: "shell" }),
    executablePath: await chromium.executablePath(),
    headless: "shell",
  });
  return browser;
}

export const handler = async (event) => {
  const { html } = JSON.parse(event.body ?? "{}");
  const page = await (await getBrowser()).newPage();
  try {
    await page.setContent(html, { waitUntil: "networkidle0", timeout: 20000 });
    const pdf = await page.pdf({ format: "A4", printBackground: true });
    const key = `pdfs/${randomUUID()}.pdf`;
    await s3.send(new PutObjectCommand({
      Bucket: process.env.BUCKET, Key: key, Body: pdf, ContentType: "application/pdf",
    }));
    return { statusCode: 200, body: JSON.stringify({ key }) };
  } finally {
    await page.close();
  }
};

Most of that handler exists because of platform constraints:

  • Package size. Lambda allows 50 MB zipped for direct upload and 250 MB unzipped including layers (Lambda quotas). The compressed Chromium in @sparticuz/chromium is over 50 MB, which is why it is usually shipped as a layer or fetched at runtime with @sparticuz/chromium-min.
  • Version pinning. Each Puppeteer release expects a specific Chromium version, so puppeteer-core and @sparticuz/chromium are upgraded together. The @sparticuz/chromium README notes that it follows Chromium's release cycle and that breaking changes can land in a patch version.
  • Memory. The same README recommends at least 512 MB and 1600 MB or more. Lambda allocates CPU in proportion to memory, with the equivalent of one vCPU at 1,769 MB.
  • Response size. Synchronous Lambda responses are capped at 6 MB, so PDFs go to S3 and the function returns a key or a presigned URL.
  • Fonts. The Lambda runtime has no fonts installed. @sparticuz/chromium ships Open Sans (Latin, Greek, Cyrillic); CJK, Arabic, Indic scripts and emoji need a font layer that you build and maintain.
  • Concurrency. The default account limit is 1,000 concurrent executions per region, shared with every other function in the account. One render occupies one execution environment for its full duration.
  • Playwright variant. playwright-core works with the same package (args: chromium.args, executablePath: await chromium.executablePath()). The README documents that Playwright does not clean up its user data directory on warm Lambdas, so /tmp fills up unless you pass a unique --user-data-dir per invocation and delete it afterwards.

On containers (ECS, Kubernetes, a VM), the list changes but does not shrink. Chromium spawns child processes, so a container without an init process collects zombies; Puppeteer's troubleshooting guide recommends docker run --init or dumb-init as PID 1. Docker's default /dev/shm is 64 MB, which is small for Chromium, so teams raise --shm-size or launch with --disable-dev-shm-usage. A crashed or hung page that skips browser.close() leaks memory until the container is recycled. And if users can supply HTML, that HTML can make network requests from inside your network, so the renderer needs egress rules that keep it away from internal services and cloud metadata endpoints.

The cost model

Prices used (list prices, October 2026)

ItemPriceSource
Api2Pdf platform fee$1.00 / monthApi2Pdf pricing
Api2Pdf bandwidth$0.001 / MBApi2Pdf pricing
Api2Pdf compute$0.00019551 / secondApi2Pdf pricing
Lambda duration (x86, first tier)$0.0000166667 / GB-secondAWS Lambda pricing
Lambda duration (Arm, first tier)$0.0000133334 / GB-secondAWS Lambda pricing
Lambda requests$0.20 / 1M requestsAWS Lambda pricing
S3 Standard PUT$0.005 / 1,000 requestsAmazon S3 pricing
Fargate (Linux/x86, us-east-1)$0.04048 / vCPU-hour, $0.004445 / GB-hourAWS Fargate pricing

Assumptions (change these to match your workload)

  • Average PDF: 0.1 MB and 2 seconds of render time, the same defaults as the calculator on the Api2Pdf pricing page.
  • Lambda configured with 2,048 MB (2 GB, close to the 2 GB per request on Api2Pdf's standard line), x86, us-east-1, billed for the same 2 seconds.
  • One S3 PUT per PDF. S3 storage, internet egress, CloudWatch Logs, API Gateway and NAT are left out to keep the arithmetic readable; each would add to the self-hosted side. The Lambda free tier is also left out; it would subtract at most $6.87 a month (400,000 GB-seconds and 1M requests).
  • Cold starts are not included in the base case (see below).

The formulas

// cost.js: redo the math with your own numbers
function monthly({ pdfs, mb = 0.1, seconds = 2, lambdaGb = 2 }) {
  const api2pdf = 1 + pdfs * mb * 0.001 + pdfs * seconds * 0.00019551;
  const lambda =
    pdfs * seconds * lambdaGb * 0.0000166667 + // duration
    pdfs * 0.20 / 1e6 +                         // requests
    pdfs * 0.005 / 1000;                        // S3 PUT
  return { api2pdf: api2pdf.toFixed(2), lambda: lambda.toFixed(2), gap: (api2pdf - lambda).toFixed(2) };
}

for (const pdfs of [10_000, 100_000, 1_000_000, 10_000_000]) {
  console.log(pdfs, monthly({ pdfs }));
}

Results

PDFs / monthApi2PdfLambda + S3 (infra only)Monthly gapGap in engineer-hours at $100/h
10,000$5.91$0.72$5.190.05 h (3 minutes)
100,000$50.10$7.19$42.920.43 h (26 minutes)
1,000,000$492.02$71.87$420.154.2 h
10,000,000$4,911.20$718.67$4,192.5341.9 h

Worked example at 1M PDFs: Api2Pdf is $1 + (1,000,000 x 0.1 MB x $0.001) + (1,000,000 x 2 s x $0.00019551) = $1 + $100 + $391.02 = $492.02. Lambda is 1,000,000 x 2 s x 2 GB = 4,000,000 GB-seconds x $0.0000166667 = $66.67, plus $0.20 in requests and $5.00 in S3 PUTs = $71.87.

The $100/hour engineering rate is an assumption, not a benchmark. Use your own fully loaded rate. The last column answers one question: how many hours per month can your team spend on the renderer before self-hosting stops saving money?

Adjustments that move the numbers

  • Cold starts are billed. AWS bills initialization code declared outside the handler as part of duration, and a browser launched on the first invocation (as in the handler above) is billed as normal duration. Either way, launching Chromium on a new execution environment adds billed seconds that the base case leaves out. Your real figures are in the Init Duration and Billed Duration fields of the Lambda REPORT log lines. As an illustration only: if 5% of invocations were cold and each init took 3 seconds at 2 GB, that adds 1,000,000 x 0.05 x 3 s x 2 GB x $0.0000166667 = $5.00 per million PDFs.
  • CPU per render. At 2 GB, Lambda gives roughly 1.16 vCPU. Api2Pdf's standard line allocates 2 CPUs per request. If your renders are CPU-bound, matching that on Lambda means about 3.5 GB, which roughly doubles the Lambda duration cost, or accepting longer render times, which raises it too.
  • Arm. Arm Lambda is $0.0000133334 per GB-second, 20% below x86. @sparticuz/chromium has published arm64 builds since Chromium v135.
  • Always-on containers. Two Fargate tasks with 2 vCPU and 4 GB each, running 730 hours, cost 2 x [(2 x $0.04048 + 4 x $0.004445) x 730] = $144.16 per month before a load balancer, at any volume they can absorb. That beats Lambda at sustained high volume and costs more than Api2Pdf below roughly 300,000 PDFs a month under the assumptions above.
  • Bandwidth. Api2Pdf bills $0.001 per MB of output. If your PDFs are large (image-heavy reports), bandwidth becomes a larger share of the bill. Run a sample through the API and read MbOut and Cost from the response to get your real per-PDF figure.

The work that does not show up on the AWS bill

These are the recurring tasks behind the "engineer-hours" column. None of them is hard on its own. Together they are the reason teams move to an API:

  • Upgrading Puppeteer or Playwright and the matching Chromium build, then re-checking output for layout drift.
  • Building and versioning a font layer when a customer's invoice needs Japanese, Arabic or emoji.
  • Investigating timeouts on pages with slow third-party scripts, charts that paint after load, or images behind auth.
  • Tuning memory and timeouts after a large report runs out of RAM.
  • Requesting concurrency increases and handling throttling during traffic spikes.
  • Cleaning up /tmp, leaked pages and zombie processes.
  • Network isolation for renderers that load user-supplied HTML.
  • Adding the next format: Word to PDF (LibreOffice), merge, watermark, password protection or PDF to Markdown, each with its own binary and its own upgrade cycle.

With Api2Pdf, the same key covers Headless Chrome, wkhtmltopdf, LibreOffice, PdfSharp merge, extract pages, watermark and password, ZXing barcodes, MarkItDown and OpenDataLoader. There are no rate limits; each request gets 2 GB of RAM and 90 seconds, and V2 XL (https://v2-xl.api2pdf.com) gives 4 CPU, 8 GB and 5 minutes per request at 10x the standard price for heavy documents.

When self-hosting makes sense

  • Very high, steady volume with a platform team. At 10M PDFs a month the gap in the table is about $4,200, which pays for a meaningful amount of engineering time. If you already run containers at scale, a Chromium pool is one more service.
  • You already run Playwright or Puppeteer. If browser automation is part of your product (scraping, logged-in flows, end-to-end tests), the infrastructure and expertise exist and PDF export is a small addition.
  • Data cannot leave your network. Rendering stays inside your own VPC or on-premises. If you want the Api2Pdf API surface there, the Enterprise Self-Hosted Docker container is a one-time $7,900 and runs in your infrastructure; Enterprise Managed Hosting gives you an isolated environment that Api2Pdf runs for you.
  • Tight latency budgets. A warm, in-process browser avoids a network round trip and file download, which matters for interactive previews measured in hundreds of milliseconds.
  • Custom Chromium builds or flags that a hosted service does not expose.

The API makes sense when volume is below a few million PDFs a month, traffic is spiky, the team is small, or you need more than one document format. At 100,000 PDFs a month the entire Api2Pdf bill is about $50, roughly half an hour of engineering time at the assumed rate.

Measure it with your own templates

The fastest way to replace the assumptions with real numbers: send ten of your actual templates to /chrome/pdf/html, read Seconds, MbOut and Cost from each response, and plug the averages into cost.js above. Do the same with your Lambda REPORT lines for the self-hosted side.

New accounts get one month of free credit. Get an API key at portal.api2pdf.com and run the comparison today.

Related reading: