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/chromiumis 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-coreand@sparticuz/chromiumare upgraded together. The@sparticuz/chromiumREADME 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/chromiumships 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-coreworks 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/tmpfills up unless you pass a unique--user-data-dirper 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)
| Item | Price | Source |
|---|---|---|
| Api2Pdf platform fee | $1.00 / month | Api2Pdf pricing |
| Api2Pdf bandwidth | $0.001 / MB | Api2Pdf pricing |
| Api2Pdf compute | $0.00019551 / second | Api2Pdf pricing |
| Lambda duration (x86, first tier) | $0.0000166667 / GB-second | AWS Lambda pricing |
| Lambda duration (Arm, first tier) | $0.0000133334 / GB-second | AWS Lambda pricing |
| Lambda requests | $0.20 / 1M requests | AWS Lambda pricing |
| S3 Standard PUT | $0.005 / 1,000 requests | Amazon S3 pricing |
| Fargate (Linux/x86, us-east-1) | $0.04048 / vCPU-hour, $0.004445 / GB-hour | AWS 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 / month | Api2Pdf | Lambda + S3 (infra only) | Monthly gap | Gap in engineer-hours at $100/h |
|---|---|---|---|---|
| 10,000 | $5.91 | $0.72 | $5.19 | 0.05 h (3 minutes) |
| 100,000 | $50.10 | $7.19 | $42.92 | 0.43 h (26 minutes) |
| 1,000,000 | $492.02 | $71.87 | $420.15 | 4.2 h |
| 10,000,000 | $4,911.20 | $718.67 | $4,192.53 | 41.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 DurationandBilled Durationfields of the LambdaREPORTlog 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/chromiumhas 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
MbOutandCostfrom 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:



