Puppeteer's page.pdf() prints any page to PDF through Chrome, but its defaults catch almost everyone the first time. We ran it on Puppeteer 25.15 (Chrome 155), measured what comes out wrong and how to fix each issue. Every snippet below was run as shown.
Install with npm i puppeteer, which also downloads a matching Chrome. Save as pdf.mjs:
// pdf.mjs, run with: node pdf.mjs
import puppeteer from "puppeteer";
const browser = await puppeteer.launch();
const page = await browser.newPage();
await page.goto("https://en.wikipedia.org/wiki/PDF", { waitUntil: "networkidle0" });
await page.pdf({
path: "page.pdf",
format: "A4", // the default is Letter
printBackground: true, // the default drops every background color and image
margin: { top: "1cm", bottom: "1.5cm", left: "1cm", right: "1cm" },
displayHeaderFooter: true,
headerTemplate: "<span></span>", // empty, or Chrome prints the date and title
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();
On the Wikipedia "PDF" article this gave a 24-page A4 file with "Page 2 of 24" in the footer of page 2, in 3 s. Each option in it is there to fix one of the defaults below.
We printed a small invoice page (a dark blue header, a total line and one @media print rule) with each setting and read the PDFs with poppler's pdfinfo, pdftotext -bbox and pdftoppm.
| Default | What we measured |
|---|---|
| Page size is Letter Fix: format: "A4" | 612 × 792 pt (US Letter) when no format is passed, wherever the machine is. |
| Backgrounds dropped Fix: printBackground: true | The header's pixel was rgb(255, 255, 255) in the default PDF and rgb(30, 58, 138) with the option. White text on a dropped background disappears. |
| Footer text is ~1 pt tall Fix: set font-size and width:100% in the template | A footer template with no font size printed "Page 1" 0.9 pt high at x = 0, in the corner. With font-size:9px;width:100%;text-align:center it was 8 pt high and centered. |
CSS @page { size } ignoredFix: preferCSSPageSize: true | A page with @page { size: A5 } came out A4 when format: "A4" was passed. Only preferCSSPageSize: true gave A5 (420 × 595 pt). |
| No bookmarks Fix: outline: true | The default PDF has no outline. With outline: true, the headings become a bookmark tree in the PDF viewer's sidebar. |
Two more things that are easy to miss:
page.pdf() renders with @media print rules: our "screen only" paragraph was missing from the PDF. Call await page.emulateMediaType("screen") first to get what you see in the browser.document.fonts.ready before printing (waitForFonts, on by default), but images still loading are printed blank. Use waitUntil: "networkidle0", and scroll pages with lazy images first (lazy images in PDFs). The print itself times out after 30 s; pass timeout: 0 to page.pdf() for very long documents.For invoices and reports you usually have HTML, not a URL. setContent loads it, and emulateMediaType makes the PDF match the screen styles:
// html_pdf.mjs: a PDF from an HTML string, styled like the screen
import puppeteer from "puppeteer";
const html = `<html><body style="font-family:sans-serif">
<div style="background:#1e3a8a;color:#fff;padding:24px"><h1>Invoice 42</h1></div>
<p>Total: 120 EUR</p></body></html>`;
const browser = await puppeteer.launch();
const page = await browser.newPage();
await page.setContent(html, { waitUntil: "networkidle0" });
await page.emulateMediaType("screen"); // ignore @media print rules
await page.pdf({ path: "invoice.pdf", format: "A4", printBackground: true });
await browser.close();
This gave a one-page A4 PDF with the blue header kept. Images and fonts in the HTML need absolute URLs (or data URLs), since a setContent page has no base URL. More on fonts in custom fonts in PDFs.
Since Puppeteer 22, page.pdf() returns a Uint8Array, not a Node Buffer. fs.writeFileSync doesn't care, but Express 4's res.send() only treats Buffers as binary. We sent the raw result from Express 4.22: the client got 161,745 bytes of JSON ({"0":37,"1":80,"2":68,...}, served as application/pdf) instead of a 15,024-byte PDF, which PDF viewers report as damaged. Express 5 sends it correctly, but wrapping it in Buffer.from() works on both:
// server.mjs: serve a PDF from Express, run with: node server.mjs
import express from "express";
import puppeteer from "puppeteer";
const browser = await puppeteer.launch(); // one browser, reused by every request
const app = express();
app.get("/invoice.pdf", async (req, res) => {
const page = await browser.newPage();
try {
await page.setContent("<h1>Invoice 42</h1><p>Total: 120 EUR</p>");
const pdf = await page.pdf({ format: "A4", printBackground: true });
// page.pdf() returns a Uint8Array: wrap it, or Express 4 sends it as JSON
res.type("application/pdf").send(Buffer.from(pdf));
} finally {
await page.close();
}
});
app.listen(3000);
We ran this server and fetched /invoice.pdf: Content-Type: application/pdf and a valid one-page PDF. Launch the browser once and open a page per request: starting a new Chrome for every request is slow and memory-hungry, and a crashed request can leave the process behind.
Could not find Chrome (ver. 155.0.8059.84): the Chrome download didn't happen (often PUPPETEER_SKIP_DOWNLOAD was set, or the cache in ~/.cache/puppeteer isn't in your Docker image). Run npx puppeteer browsers install chrome, or point PUPPETEER_EXECUTABLE_PATH at an installed Chrome.No usable sandbox! on Ubuntu 23.10+ and in many containers, which restrict unprivileged user namespaces. The quick fix is puppeteer.launch({ args: ["--no-sandbox"] }), which we needed for every snippet on this page; only use it for pages you trust.break-inside: avoid (see page breaks).If PDFs are a side feature, running Chrome means fonts, system libraries, sandbox flags and memory limits in your deployment. Shotmint runs the same Chrome print engine and sets the sensible defaults for you: A4, backgrounds on, 10 mm margins and lazy images scrolled into view before printing.
// api_pdf.mjs, run with: SHOTMINT_KEY=... node api_pdf.mjs
import { writeFileSync } from "node:fs";
const res = await fetch("https://shotmint.creasework.com/v1/pdf", {
method: "POST",
headers: { "X-API-Key": process.env.SHOTMINT_KEY, "Content-Type": "application/json" },
body: JSON.stringify({
url: "https://en.wikipedia.org/wiki/PDF", // or html: "<h1>Invoice 42</h1>"
format: "A4",
margin: "10mm",
page_numbers: true, // centered "1 / 24" footer
}),
});
if (!res.ok) throw new Error(`${res.status}: ${await res.text()}`);
writeFileSync("page.pdf", Buffer.from(await res.arrayBuffer()));
console.log("credits left:", res.headers.get("x-credits-remaining"));
On the same Wikipedia page this returned a 24-page A4 PDF in 2.7 s, with "2 / 24" on page 2. The options map to Puppeteer's: format, landscape, margin (with a unit, e.g. "0mm"), scale, print_background, screen_media (the emulateMediaType fix), header_template and footer_template (same placeholders as Puppeteer), and wait_until. Python, with the standard library only:
# api_pdf.py, run with: SHOTMINT_KEY=... python api_pdf.py
import json, os, urllib.request
req = urllib.request.Request(
"https://shotmint.creasework.com/v1/pdf",
data=json.dumps({
"url": "https://en.wikipedia.org/wiki/PDF",
"format": "A4",
"page_numbers": True,
}).encode(),
headers={"X-API-Key": os.environ["SHOTMINT_KEY"], "Content-Type": "application/json"},
)
with urllib.request.urlopen(req, timeout=90) as res, open("page.pdf", "wb") as f:
f.write(res.read())
Related: Puppeteer screenshots, Playwright PDF, HTML to PDF in Node.js, Puppeteer alternatives, free HTML to PDF converter, API reference.
One PDF = one credit, however many pages it has. €5 buys 2,000 renders, credits never expire and there's no subscription.
No signup, no card. Get a key in one click, then buy credits only if you need more (from €5 for 2,000, never expire). From a script, an AI agent, Make or Zapier, POST /v1/keys/free (no body) returns a key as JSON (openapi.json, no-code recipes).
All guides: guides · integrations · comparisons