Puppeteer vs Playwright vs a PDF API for HTML-to-PDF
Puppeteer, Playwright, and a hosted PDF API all render the same pixels — all three drive real Chromium under the hood, so fidelity is identical. The difference is entirely operational: who manages the browser binary, the memory leaks, the serverless cold starts, and the timeout tuning. Self-host if you already run long-lived servers and need deep page-automation control beyond printing; use an API if you just need a PDF back from HTML, a URL, or a template.
They render the same thing
This is the part people get wrong first: Puppeteer and Playwright are not competing rendering engines. Puppeteer drives Chrome/Chromium via the DevTools Protocol; Playwright drives Chromium (plus Firefox and WebKit) via its own protocol layer. For HTML-to-PDF specifically, both end up calling the same underlying Chromium print pipeline — the one behind Chrome's 'Print to PDF'. If your CSS renders correctly in Chrome, it will render identically through either library. See what a headless Chromium PDF engine actually is for how that print pipeline handles page-break CSS, @media print, and web fonts.
A hosted PDF API is not a third rendering engine either — a well-built one (Docweave included) is Chromium under the hood too. So the fidelity question — will my flexbox layout, my custom font, my page-break-inside: avoid survive — has the same answer across all three options. The real decision is who owns the operational burden.
Puppeteer and Playwright: the code is the easy part
A minimal Puppeteer render looks like this:
const puppeteer = require('puppeteer');
(async () => {
const browser = await puppeteer.launch();
const page = await browser.newPage();
await page.goto('https://example.com/invoice/123', { waitUntil: 'networkidle0' });
const pdfBuffer = await page.pdf({ format: 'A4', printBackground: true });
await browser.close();
require('fs').writeFileSync('invoice.pdf', pdfBuffer);
})();Playwright's equivalent is nearly line-for-line the same shape, with a slightly different API surface and a built-in page.pdf({ path }) convenience:
const { chromium } = require('playwright');
(async () => {
const browser = await chromium.launch();
const page = await browser.newPage();
await page.goto('https://example.com/invoice/123', { waitUntil: 'networkidle' });
await page.pdf({ path: 'invoice.pdf', format: 'A4', printBackground: true });
await browser.close();
})();Neither of these is hard to write. The pain shows up later: keeping the Chromium binary patched, sizing memory limits so pages don't OOM under load, handling page.goto hangs on slow third-party scripts, and — the big one — making this work in a serverless function where the deploy bundle has a size limit and the runtime has no persistent filesystem or long-lived process to reuse a warm browser.
The serverless tax
If you deploy on Vercel, Lambda, or Cloud Run, you can't just npm install puppeteer and ship it — the full Chromium download is too large for most function bundle limits. The standard workaround is a trimmed binary like @sparticuz/chromium, which shaves the package down but adds its own version-pinning headaches (the Chromium build has to match the Puppeteer/Playwright version you're using, and that pairing breaks more often than you'd like). Cold starts also get worse: launching a fresh Chromium process on every invocation routinely adds 1-3 seconds before your page.goto even fires, and there's no way around that without keeping a browser warm between invocations — which means paying for idle compute or building your own pooling layer.
None of this is exotic engineering. It's just recurring maintenance work that has nothing to do with your product. If you're building against Next.js specifically, this is exactly the friction covered in generating PDFs from a Next.js API route — the framework's serverless-first deploy model and a self-managed Chromium binary don't mix cleanly.
What a hosted API removes
A PDF API's whole job is to have already solved the Chromium-in-production problem, so you get a normal HTTP request instead. With Docweave, the REST call looks like this — note the Accept: application/pdf header and saving the raw response, not JSON:
curl https://docweave.dev/api/v1/pdf \
-H "Authorization: Bearer dw_live_your_key" \
-H "Content-Type: application/json" \
-H "Accept: application/pdf" \
-d '{"source": {"type": "url", "url": "https://example.com/invoice/123"}}' \
--output invoice.pdfOr from Node, buffering the arrayBuffer response and writing it to disk (full walkthrough at generating PDFs from Node.js):
const res = await fetch('https://docweave.dev/api/v1/pdf', {
method: 'POST',
headers: {
Authorization: 'Bearer dw_live_your_key',
'Content-Type': 'application/json',
Accept: 'application/pdf',
},
body: JSON.stringify({
source: { type: 'url', url: 'https://example.com/invoice/123' },
idempotencyKey: 'invoice-123-v1',
}),
});
const buffer = Buffer.from(await res.arrayBuffer());
require('fs').writeFileSync('invoice.pdf', buffer);If you're wiring this up as a tool call for an LLM agent rather than a backend route, the open-source @docweave/mcp server exposes the same rendering through generate_pdf — no HTTP client code, no API-key plumbing in your agent's tool layer:
await generate_pdf({
source: { type: 'url', url: 'https://example.com/invoice/123' },
outputPath: './invoice.pdf',
});The idempotencyKey in the fetch example matters more than it looks: if your job runner retries a request after a timeout, an idempotency key stops you from billing (or emailing) the same invoice twice. That pattern comes up constantly in AI agents that need to produce PDFs — agent loops retry on ambiguous failures far more often than a human-driven workflow does.
The actual cost comparison
Self-hosting isn't free even though there's no per-document line item. You're paying in compute (a Chromium process idling or cold-starting), in engineering time (patching, pooling, debugging the one PDF that renders blank because a web font didn't load in time), and in on-call risk (a memory leak in a long-running browser process is a 2am page, not a support ticket). A hosted API converts all of that into a predictable per-document price — see Docweave's pricing — and if you're trying to decide whether that trade is worth it at your volume, run your numbers through the PDF cost calculator rather than guessing. For a broader field of hosted options beyond Docweave, our roundup of PDF generation APIs is a fair starting point.
When to actually self-host
Self-hosting still makes sense when PDF generation is one small piece of a much larger browser-automation need — you're already running Playwright for end-to-end tests or scraping, and printing is incidental. It also makes sense at very high, steady volume where you have the ops team to run browser pooling properly, or when data residency rules mean the rendering literally cannot leave your infrastructure. If none of those apply and you just want HTML or a URL to become a correct PDF without owning a browser fleet, a hosted API is the less risky default. Full API reference is at the docs.
The short version
Puppeteer vs Playwright is a near wash for PDF output — pick whichever fits your existing test/automation stack, or a PDF API if the browser lifecycle isn't work you want to own. All three produce the same Chromium-rendered pixels; they just disagree about who's on the hook when the browser process misbehaves at 2am.
FAQ
Does Puppeteer or Playwright produce better-looking PDFs?
Neither — both drive Chromium's own print pipeline for page.pdf(), so output fidelity (CSS support, fonts, page breaks) is effectively identical. Playwright adds multi-browser support and a slightly more modern API, which matters for test automation but not for PDF quality.
Can I get the same rendering fidelity from a hosted PDF API?
Yes, provided the API is genuinely Chromium-based (Docweave is). The rendering engine is the same; what changes is who manages the browser process, scaling, and timeouts.
Why does Puppeteer/Playwright feel harder to deploy on Vercel or Lambda than locally?
Serverless functions have bundle-size limits that the full Chromium download exceeds, so you need a trimmed binary package, and each cold invocation typically launches a fresh browser process, adding real latency. Locally, a long-running Node process can keep the browser warm; serverless generally can't.
Is a hosted PDF API more expensive than self-hosting?
It depends on volume and whether you count engineering time. At low-to-moderate volume, per-document API pricing is usually cheaper than the compute plus maintenance cost of running Chromium reliably yourself. At very high, steady volume with an ops team already in place, self-hosting can win — model your specific numbers instead of assuming either way.
Related
Generate your first PDF in minutes.
Get an API key