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.