PDF generation in Node.js without running Puppeteer yourself
Puppeteer works until it ships to a 512 MB container. Here is the same result — HTML in, PDF out — from Node with fetch, plus a webhook-driven async pattern.
Updated 31 August 2026
Every Node PDF tutorial ends with `puppeteer.launch()`. It is a fine answer on a laptop. In production it means a 400 MB Chromium download in your image, a list of apt packages, a sandbox flag, and a process that grows until something restarts it. If the documents are the product, you run it. If they are a feature, you probably should not.
Synchronous
import { writeFile } from "node:fs/promises";
const res = await fetch("https://pdfgeny.com/api/v1/render", {
method: "POST",
headers: { Authorization: `Bearer ${process.env.PDFGENY_KEY}`, "Content-Type": "application/json" },
body: JSON.stringify({ html: "<h1>Hello</h1>", format: "A4", footer_html: "Page {{page}} of {{pages}}" }),
});
if (!res.ok) throw new Error((await res.json()).error.message);
await writeFile("hello.pdf", Buffer.from(await res.arrayBuffer()));
No dependencies: `fetch` is built into Node 18+.
Asynchronous with a webhook
For anything user-triggered, do not make the HTTP request wait on a render. Queue it and let a webhook tell you when it is done:
const job = await fetch("https://pdfgeny.com/api/v1/jobs", {
method: "POST",
headers: { Authorization: `Bearer ${KEY}`, "Content-Type": "application/json" },
body: JSON.stringify({ url: "https://app.example.com/reports/42?token=…", webhook_url: "https://app.example.com/hooks/pdf" }),
}).then(r => r.json());
// store job.id against the report; respond to the user immediately
Your webhook handler receives `{ event: "job.finished", job: { id, status, document_url } }` and a signature header. Verify it — the secret is in your dashboard:
import { createHmac, timingSafeEqual } from "node:crypto";
export function verify(rawBody, header, secret) {
const expected = "sha256=" + createHmac("sha256", secret).update(rawBody).digest("hex");
return timingSafeEqual(Buffer.from(expected), Buffer.from(header || ""));
}
Use the raw request body, not the parsed JSON — re-serialising changes bytes and the signature will not match.
When to keep Puppeteer
You need to interact with the page before printing (log in, click through a wizard), or you render tens of thousands a day and have someone to own the browser fleet. Otherwise the API call above is the whole integration.
Try it: convert HTML to PDF in the browser, or get a free API key — 50 documents a month.
More guides
Writing HTML templates for PDF: a style guide (Jinja, Django, Handlebars)
Templates that render well as PDFs follow a few rules: fixed page geometry, no external state, defensive filters, print CSS. Examples in Jinja and Django syntax.
Issuing 5,000 course certificates in one afternoon
A spreadsheet of names, one landscape template, batch requests of 100, and signed links per student. The full workflow including naming, verification codes and delivery.
Generating receipts at checkout without slowing the checkout
The receipt PDF should never sit between the customer and the order confirmation. A pattern: confirm first, render asynchronously, attach when ready.
URL-to-PDF and SSRF: how a PDF renderer becomes an attack surface
A renderer that fetches URLs can be pointed at your internal network. The attacks, the defences, and what to demand from a PDF API vendor.