Skip to content

Puppeteer PDF alternative: when to stop running headless Chrome yourself

, 6 min read

Puppeteer is the usual first answer to “how do I make a PDF from HTML”: launch Chrome, load the page, call page.pdf(). For a few documents on a machine you control, that’s a fine answer. This guide is about the point where running the browser becomes the job, and how to move the same options to an API without rewriting your templates.

Signs it’s time to stop running Chrome yourself

  • You restart it on a schedule. Long-running browsers leak memory; if a cron job or a health check restarts yours, you’re maintaining a browser fleet.
  • The HTML or URL comes from users. A page can request http://169.254.169.254/ or your internal services from inside your network. Blocking that properly means checking the page and every sub-resource, after redirects and DNS.
  • Installs are fragile. npm i puppeteer downloads a compatible Chrome during installation, and Puppeteer’s own README warns that modern package managers block install scripts by default, so the browser may silently not be there at runtime.
  • Fonts differ between laptop and server. Slim images have almost no fonts; text falls back to something else, and CJK or emoji turn into boxes.
  • Load is spiky. Month-end invoices or a batch job need many browser slots for an hour, then none.

If none of these apply, keep Puppeteer. If two or more do, an API is usually cheaper than the time you spend on the browser.

The Puppeteer code you have now

render.mjs (Puppeteer)
import puppeteer from "puppeteer";

const browser = await puppeteer.launch();
const page = await browser.newPage();
await page.setContent(html, { waitUntil: "networkidle0" });
const pdf = await page.pdf({
  format: "A4",
  printBackground: true,
  margin: { top: "20mm", bottom: "20mm", left: "16mm", right: "16mm" },
  displayHeaderFooter: true,
  footerTemplate: '<div style="font-size:9px;width:100%;text-align:center">Page <span class="pageNumber"></span> of <span class="totalPages"></span></div>',
});
await browser.close();

The same document through the API

render.mjs (pagenull)
const res = await fetch("https://api.pagenull.dev/v1/pdf", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.PAGENULL_API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    html,
    waitUntil: "networkidle",
    format: "A4",
    margin: { top: "20mm", bottom: "20mm", left: "16mm", right: "16mm" },
    footerHtml: '<div style="font-size:9px;width:100%;text-align:center">Page <span class="pageNumber"></span> of <span class="totalPages"></span></div>',
  }),
});
if (!res.ok) throw new Error((await res.json()).error.message);
const pdf = Buffer.from(await res.arrayBuffer());

The renderer is Chromium too, so the same HTML and CSS print the same way. Your templates don’t change.

Option by option

PuppeteerpagenullNote
page.setContent(html) / page.goto(url)html / urlExactly one of them. URLs must be public.
waitUntil: "networkidle0"waitUntil: "networkidle"Also load (default) and domcontentloaded.
page.waitForSelector(sel)waitFor: selCapture once your script marks the page ready.
format, landscape, scale, pageRangessame namesFormats: A4, Letter, Legal, A3, A5, Tabloid.
width, heightsame namesSet both. height: "auto" fits one page to the content.
marginmarginmm, cm, in or px.
printBackgroundprintBackgroundOn by default here; Puppeteer’s default is off.
preferCSSPageSizepreferCssPageSizeOn by default here, so a CSS @page size wins.
displayHeaderFooter + headerTemplate / footerTemplateheaderHtml / footerHtmlSame pageNumber / totalPages classes. Or just pageNumbers: true.
page.setJavaScriptEnabled(false)javascript: false
Request interception to block outside requestsallowNetwork: falseOnly the page itself loads.
timeouttimeoutMsUp to 60,000 ms.

Two things change in how you run it. Retry on 429 and 503 after the Retry-After header, and send an Idempotency-Key header when you retry, so a timeout on your side never bills a render twice. Full list in the API reference.

What you keep, and what you don’t

You keep your HTML and CSS. You stop owning the browser version, fonts, memory, restarts and the network rules for untrusted pages. pagenull keeps no copy of the document; each response also carries a signed receipt for the exact bytes, which is described in how to prove a PDF wasn’t altered.

Sources

Facts about other projects and rules, checked on 4 November 2026.