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.
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 decision, and a process that grows until something restarts it. If documents are your product, run it. If documents are a feature of your product, you probably should not.
Synchronous, no dependencies
Node 18+ ships fetch, so this needs nothing from npm:
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>Invoice 001</h1>",
format: "A4",
footer_html: "Page {{page}} of {{pages}}",
}),
});
if (!res.ok) {
const { error } = await res.json();
throw new Error(`${error.code}: ${error.message}`);
}
await writeFile("invoice.pdf", Buffer.from(await res.arrayBuffer()));
Note the error branch: a failed render returns JSON, not a PDF, so checking res.ok before treating the body as bytes saves you from writing an error message into a .pdf file.
Asynchronous, with a webhook
For anything user-triggered, do not make the HTTP request wait on a render. Queue it and respond immediately:
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/${id}?token=${signed}`,
webhook_url: "https://app.example.com/hooks/pdf",
wait_for: ".charts-rendered",
}),
}).then((r) => r.json());
await db.reports.update(id, { pdfJobId: job.id, pdfStatus: "queued" });
// respond to the user now — the PDF arrives in a few seconds
Verifying the webhook
Deliveries carry an HMAC signature. Verify it against the raw body — re-serialising parsed JSON changes bytes and the signature will not match:
import { createHmac, timingSafeEqual } from "node:crypto";
import express from "express";
const app = express();
app.post("/hooks/pdf",
express.raw({ type: "application/json" }),
(req, res) => {
const expected =
"sha256=" + createHmac("sha256", process.env.PDFGENY_WEBHOOK_SECRET)
.update(req.body).digest("hex");
const given = req.header("X-PDFGeny-Signature") || "";
if (expected.length !== given.length ||
!timingSafeEqual(Buffer.from(expected), Buffer.from(given))) {
return res.status(400).send("bad signature");
}
const { job } = JSON.parse(req.body.toString());
if (job.status === "done") {
void attachToReport(job.id, job.document_url);
}
res.sendStatus(200);
});
Get the secret from GET /api/v1/webhook-secret; POST to the same endpoint rotates it. Failed deliveries retry five times over about three hours, so a brief outage on your side is not a lost document.
Streaming to the client
If a user clicks "Download PDF" and you want to proxy rather than store:
app.get("/invoices/:id.pdf", async (req, res) => {
const upstream = await fetch("https://pdfgeny.com/api/v1/render", {
method: "POST",
headers: { Authorization: `Bearer ${KEY}`, "Content-Type": "application/json" },
body: JSON.stringify({ template: "invoice", data: await invoiceData(req.params.id) }),
});
if (!upstream.ok) return res.status(502).send("render failed");
res.setHeader("Content-Type", "application/pdf");
res.setHeader("Content-Disposition", `attachment; filename="${req.params.id}.pdf"`);
Readable.fromWeb(upstream.body).pipe(res);
});
For anything slower than about a second, prefer the async pattern — users abandon spinners faster than they abandon emails.
When to keep Puppeteer
Three cases where running the browser yourself is the right call:
1. You interact with the page before printing — log in, click through a wizard, fill a form. An API that takes a URL cannot do that for you. 2. Volume is high and steady. At tens of thousands of documents a day a dedicated render box is cheaper than per-document pricing. 3. The documents cannot leave your network for regulatory reasons.
Everything else — a few thousand documents a month, spiky traffic, a small team — is better served by one HTTP call. The engine is identical; the difference is who gets paged when it dies.
If you do run it yourself
Read how to generate 10,000 PDFs without running out of memory first. The five failure modes there are the ones that bite Node teams specifically, because Promise.all makes unbounded concurrency so easy to write.