Tutorials

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.

M Mikel Rougstone · 31 August 2026 · 3 min read
PDF generation in Node.js without running Puppeteer yourself

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.

M

Mikel Rougstone

Founder, PDFGeny

I build and run PDFGeny — the API, the rendering fleet and the template catalog. Most of what I write here comes from something that broke in production first.