Keep Puppeteer when you need a real browser session. Use a hosted HTML to PDF API when you already have HTML (or Markdown, or a template) and the document is the product. RelayPDF is one of those APIs: POST UTF-8 JSON with html, a public url, markdown, or templateId to https://api.relaypdf.com/v1/pdf and take a PDF. Product page: https://relaypdf.com/html-to-pdf. Operating-model compare: https://relaypdf.com/compare/puppeteer.
When to use which
Stay on Puppeteer (or Playwright) if any of these are true:
- You must drive a login, MFA, or cookie jar you cannot flatten into HTML you already own.
- You need Chrome flags, extensions, a specific binary, or CDP features an API will not expose.
- You print intranet or loopback URLs. RelayPDF's URL source rejects private, loopback, and metadata hosts. Inline the HTML or keep the browser inside the network.
- You want unlimited in-process iteration with no per-document debit. Price that against the people who keep Chrome running.
Move the printer out of process if:
- The job is setContent(html) plus page.pdf(). You do not need a browser session.
- Several services need the same print path and you do not want each one to vendor Chrome.
- You want binary, a 24h download URL, or async plus webhook without writing that layer.
- You also need merge, Office convert, barcodes, or zip as documented sibling endpoints.
- Failed, invalid, unauthorized, and rate-limited requests should not be billed. RelayPDF's pricing page states they are never billed.
Wrapping Puppeteer in an internal API you own is still a valid PDF API. You become the vendor. If callers include agents as well as apps, RelayPDF's compare page puts CLI and MCP in the built-in column versus custom tooling.
page.pdf() defaults that bite
In current Puppeteer docs (v25), Page.pdf() prints the page with the print CSS media type, takes optional options, returns a Uint8Array, and writes to disk only if you pass path. Official PDFOptions: https://pptr.dev/api/puppeteer.pdfoptions.
- printBackground defaults to false. Brand colors and table zebra stripes vanish unless you opt in or force -webkit-print-color-adjust.
- timeout defaults to 30,000 ms (overridable via Page.setDefaultTimeout; 0 disables it).
- format defaults to letter. preferCSSPageSize defaults to false, so @page size loses to the paper option.
- displayHeaderFooter defaults to false. Header and footer HTML is separate from the page body.
- To print with screen media, call page.emulateMediaType('screen') first.
The call is a few lines. The rest of the job is launch, context, page, setContent or goto, wait, pdf, close, retry, and the machine that held all of it.
Puppeteer: page.pdf()
import puppeteer from "puppeteer";
const browser = await puppeteer.launch({
args: ["--no-sandbox", "--disable-dev-shm-usage"],
});
const page = await browser.newPage();
await page.setContent(html, { waitUntil: "networkidle0" });
const pdf = await page.pdf({
format: "letter",
printBackground: true,
margin: { top: "16mm", bottom: "16mm", left: "12mm", right: "12mm" },
});
await browser.close();
return pdf;
That is the happy path. Production adds a pool or a queue, crash handling, fonts on the image, tmpfs because Docker /dev/shm is often 64 MB, and a timeout story for pages that never go network-idle. On Lambda you add a layer or container image, a 512-2048 MB memory slider, and a cold start longer than the PDF. Those are general in-process Chromium costs, not RelayPDF benchmarks.
The same job as POST /v1/pdf
Documented at https://relaypdf.com/docs/pdf. Send exactly one source field (html, url, markdown, or templateId) as UTF-8 JSON. HTML is not Base64. Optional filename must end in .pdf. Option field names match across JSON, Node, and Python (printBackground, not print_background).
Default response is binary application/pdf. response: "url" returns JSON with a public download that expires in 24 hours. response: "async" plus an optional HTTPS callbackUrl defers the job. printBackground defaults to true on this endpoint, the opposite of Puppeteer's default.
RelayPDF: POST /v1/pdf and the Node SDK
curl https://api.relaypdf.com/v1/pdf
-H "Authorization: Bearer pdf_live_..."
-H "Content-Type: application/json"
-d '{"html":"<h1>Invoice #1042</h1><p>Total: $1,200.00</p>","filename":"invoice.pdf","options":{"format":"letter","printBackground":true,"margin":{"top":"16mm","bottom":"16mm","left":"12mm","right":"12mm"},"waitUntil":"networkidle0"}}'
--output invoice.pdf
// Node from https://relaypdf.com/docs/quickstart/node
import { RelayPDF } from "@relaypdf/sdk";
const client = new RelayPDF({ apiKey: process.env.RELAYPDF_API_KEY });
const pdf = await client.pdf.fromHtml("<h1>Hello</h1>", { filename: "hello.pdf" });
await pdf.save("hello.pdf");
Rendering knobs (format, landscape, margins, headerTemplate / footerTemplate, pageRanges, width / height, waitUntil, timeout max 60,000 ms, waitForSelector, cookies, extraHTTPHeaders) are listed on https://relaypdf.com/docs/options. waitUntil values on the product page are load, domcontentloaded, networkidle0, and networkidle2. The options default for waitUntil is networkidle0. Jobs that fail to render return render_failed and are not billed.
Side-by-side
Operating-model comparison, not a scorecard. RelayPDF's compare page states the same split: Puppeteer gives maximum control, but you own browser lifecycle, retries, storage, auth, and billing.
| Topic | Puppeteer page.pdf() | RelayPDF POST /v1/pdf |
|---|---|---|
| What you call | page.pdf(options) after launch / newPage / setContent or goto | One HTTP request; Node helper client.pdf.fromHtml() |
| Process model | Chromium in your VM, container, or Lambda | Managed browser ops (you do not run Chrome) |
| printBackground default | false (official PDFOptions) | true (RelayPDF HTML-to-PDF docs) |
| Timeout | 30s default; 0 disables | options.timeout is Chromium budget, max 60s |
| Sources | Any page you can load, including sessions you drive | Exactly one of html, url, markdown, templateId. URL hosts must be public |
| Response | Uint8Array; optional path on disk | binary PDF (default), url (24h expiry), or async + optional callbackUrl |
| Headers / footers | displayHeaderFooter + HTML templates (date, title, url, pageNumber, totalPages) | headerTemplate / footerTemplate; placeholders pageNumber, totalPages, date, title |
| Wait strategy | You choose waitUntil on setContent/goto, plus your own selectors | waitUntil, waitForSelector, waitForTimeout (extra wait max 30s) |
| Auth to the printer | None. It is your process | Bearer API key; wallet debit per successful job |
| Sibling work | You add merge, zip, barcodes, Office convert yourself | Documented separately: /v1/pdf/merge, /v1/convert, /v1/barcodes, /v1/zip, PDF tools |
| When it is the right tool | You need a browser, not a document endpoint | You need a document, not a browser fleet |
Memory, queues, Lambda
Open-source Chromium has no invoice. The bill is RAM, engineering time, and failed renders. A headless Chrome that can print modern CSS is commonly a few hundred megabytes resident, more under webfonts and canvas. In a long-lived Node service you either leak browsers or you write a pool (max, recycle policy, hang rule).
A queue is the next patch if checkout should not block on print. You then own visibility timeouts, poison messages, and retries for a 15-page report that died on page 14. Puppeteer is a browser driver. It will not do that.
Lambda and most FaaS are a poor home for Chromium unless you have already paid the tax: short runtime, small /tmp, sandbox versus Chrome's sandbox, cold starts. Teams pin a Chrome build, raise memory until SIGKILL stops, and still see flaky networkidle. None of this means Puppeteer is wrong on a box you control with a queue you already run. It means the marginal cost of the next thousand documents is rarely zero.
Migration mapping
Keep Puppeteer for session-driven flows. Jobs that only call setContent(html) plus page.pdf() are API candidates.
Watch the defaults when you map options. printBackground is false in official PDFOptions and true on RelayPDF /v1/pdf, so a lift-and-shift that omits the field will start printing backgrounds. timeout is 30s locally (0 disables) versus a 60s Chromium cap on the API. waitUntil default on RelayPDF options is networkidle0; extra waitForTimeout is capped at 30s. Header/footer placeholders overlap (pageNumber, totalPages, date, title) but the driver also documents url.
- Map options: format, margins, printBackground, headers, waitUntil, timeout. Defaults differ on printBackground and timeout.
- Send html as a JSON string. file:// or localhost URLs must become inline HTML or a public URL the API can fetch.
- Pick response mode: binary (the page.pdf() equivalent), url if callers should not hold a multi-MB body, or async plus callbackUrl instead of your queue.
- Office files go to POST /v1/convert. Merges go to POST /v1/pdf/merge. Do not cram those into /v1/pdf.
Limits
- Chromium render budget maxes at 60 seconds. Giant, script-heavy pages that only finish after a long client wait do not belong here without cutting work.
- URL fetch is public-web only. That is a security boundary.
- You get the documented options, not every CDP method. Fix the DOM in your app, then POST the HTML.
- This article does not claim OCR, e-sign, PDF/A, or HIPAA. Those are not on the source pages.
Pricing (verified)
Live rate card at https://relaypdf.com/pricing: prepaid wallet, no monthly tiers.
- HTML / URL / Markdown to PDF or screenshot (/v1/pdf, /v1/images): $0.015 per successful operation.
- Handlebars template to PDF (/v1/pdf + templateId): $0.015.
- LibreOffice convert $0.04, wkhtmltopdf $0.025, PDF tools / barcodes / zip $0.005, AI template generation $0.05.
- $5 credit at signup, no card required. Credit never expires. Top-up bonuses from 5% ($20 to $21) to 15% ($500 to $575).
A thousand HTML invoices at $0.015 is $15 plus any top-up bonus. Compare that to a 2 GB Lambda that sits warm, or an engineer afternoon on a Chrome bump. Run the numbers on your volume.
Request body and response modes: https://relaypdf.com/docs/pdf. Print controls: https://relaypdf.com/docs/options. Node one-pager: https://relaypdf.com/docs/quickstart/node.