·5 min read

3 ways to generate PDFs in Node.js (and when to use each)

In Node.js you generate PDFs one of three ways: drive a headless browser (Puppeteer/Playwright) to render HTML/CSS exactly as a browser would, build the PDF programmatically with a pure-JS library (pdfkit, pdf-lib), or call a hosted PDF API and skip the rendering infrastructure entirely. Headless browsers win on visual fidelity but are heavy and fragile in serverless environments; pure-JS libraries are lightweight but limited to simple, code-defined layouts; a hosted API trades a small per-document fee for zero ops burden and consistent output. Most teams outgrow at least one of the first two options before they outgrow the third.

The three approaches, in one paragraph each

If you've built more than one PDF feature in Node.js, you've probably tried at least two of these. Each is a legitimate choice — the failure mode is picking one for the wrong reason (usually: "Puppeteer is free") and then paying for it later in ops time. Here's the honest tradeoff for each, followed by a decision table you can actually use.

Option 1: headless browser (Puppeteer or Playwright)

This is real Chromium rendering your HTML and CSS, then printing it to PDF. It's the only approach that gives you pixel-accurate output for anything beyond simple text — flexbox layouts, web fonts, @media print rules, gradients, SVG charts, all render exactly as they would in a browser tab. See the glossary entry on headless Chromium PDF generation for the mechanics.

import puppeteer from 'puppeteer';

const browser = await puppeteer.launch();
const page = await browser.newPage();
await page.setContent(`
  <h1>Invoice #1042</h1>
  <p>Total: $240.00</p>
`, { waitUntil: 'networkidle0' });
const pdfBuffer = await page.pdf({ format: 'A4', printBackground: true });
await browser.close();

The catch is operational, not technical. A Chromium binary is 300+ MB, cold starts on serverless platforms (Vercel, AWS Lambda) are slow unless you use a trimmed build like @sparticuz/chromium, and you're responsible for browser crashes, zombie processes, memory leaks under load, and keeping Chromium patched. None of that is hard in isolation — it's just a second piece of infrastructure to own on top of your actual app. If your HTML source might include a URL you don't control, you also need an SSRF guard in front of it; rendering an unguarded URL is a textbook vulnerability, not a hypothetical one.

Option 2: pure-JS library (pdfkit or pdf-lib)

No browser, no native dependencies (mostly), fast cold starts. pdfkit builds a PDF by issuing drawing commands — text, lines, images — directly against the PDF spec. pdf-lib is similar but also lets you load and modify existing PDFs (fill form fields, merge pages, stamp text on a template).

import PDFDocument from 'pdfkit';
import fs from 'fs';

const doc = new PDFDocument({ size: 'A4', margin: 50 });
doc.pipe(fs.createWriteStream('invoice.pdf'));
doc.fontSize(20).text('Invoice #1042');
doc.moveDown();
doc.fontSize(12).text('Total: $240.00');
doc.end();

This works well for genuinely simple, code-defined layouts: a receipt, a shipping label, a single-column report with a logo and a table. It falls apart the moment your layout needs to look like a designed document — you're positioning text boxes by x/y coordinates instead of writing CSS, and anything resembling real typesetting (wrapping around images, multi-column layout, web fonts with proper kerning) turns into a lot of manual math. If your team's actual bottleneck is design fidelity, not bundle size, this option usually costs more time than it saves.

Option 3: a hosted PDF API

You send HTML, a URL, or a template + JSON payload to an API and get a PDF back. You don't manage a browser, you don't write drawing commands, and you don't own the render infrastructure's failure modes — the tradeoff is a per-document cost and a network hop. Docweave is built specifically for this: Chromium-quality rendering (same fidelity as option 1) behind a REST call, with no browser to babysit.

curl https://docweave.dev/api/v1/pdf \
  -H "Authorization: Bearer dw_live_your_key" \
  -H "Content-Type: application/json" \
  -H "Accept: application/pdf" \
  -d '{
    "source": { "type": "html", "html": "<h1>Invoice #1042</h1><p>Total: $240.00</p>" },
    "idempotencyKey": "invoice-1042"
  }' \
  --output invoice.pdf

From Node specifically, the same call with fetch:

import fs from 'node:fs/promises';

const res = await fetch('https://docweave.dev/api/v1/pdf', {
  method: 'POST',
  headers: {
    Authorization: 'Bearer dw_live_your_key',
    'Content-Type': 'application/json',
    Accept: 'application/pdf',
  },
  body: JSON.stringify({
    source: { type: 'html', html: '<h1>Invoice #1042</h1><p>Total: $240.00</p>' },
    idempotencyKey: 'invoice-1042',
  }),
});

if (!res.ok) throw new Error(`PDF render failed: ${res.status}`);
const pdfBuffer = Buffer.from(await res.arrayBuffer());
await fs.writeFile('invoice.pdf', pdfBuffer);

The idempotencyKey matters in practice: retries from a flaky client or a queue redelivery won't double-render or double-bill. See generating PDFs in Node.js for the full setup including templates and error handling, or what a PDF generation API actually is if you're comparing this category against rolling your own.

If you're building for an AI agent

If the caller generating the PDF is itself an LLM agent rather than your application code, an MCP server removes even the HTTP-client boilerplate. Docweave ships an open-source one:

npx @docweave/mcp
await generate_pdf({
  source: { type: 'url', url: 'https://example.com/invoice/1042' },
  outputPath: './invoice-1042.pdf',
});

This is worth calling out because it's a genuinely different integration shape from options 1 and 2 — see PDF generation for AI agents if that's your use case.

Decision table

  • Need pixel-perfect rendering of arbitrary HTML/CSS, have the ops capacity to run Chromium, and volume is low-to-moderate: Puppeteer/Playwright.
  • Need simple, code-defined layouts (labels, receipts, basic reports) and want zero native dependencies: pdfkit or pdf-lib.
  • Need browser-quality fidelity without owning browser infrastructure, are on serverless, or want billing/idempotency handled for you: a hosted API like Docweave.
  • Building for invoices, certificates, or any recurring templated document: a hosted API's template + JSON mode usually beats hand-rolling either of the other two.

One honest note on cost: a hosted API charges per document, so at very high volume with simple layouts, pdfkit can be cheaper in raw dollars — you're just paying in engineering time instead. Compare the actual numbers for your use case with the PDF cost calculator, and see Docweave's pricing if you want the concrete per-document rate rather than a hand-wave.

The pattern that actually breaks teams

The common failure isn't picking the wrong option on day one — it's picking Puppeteer for a quick internal tool, shipping it, and then discovering six months later that Chromium cold starts are eating your Lambda budget, or that a memory leak under sustained load is paging someone at 2am. That's not a reason to avoid headless browsers; it's a reason to decide up front whether you want to own that infrastructure. If the answer is no, a hosted API gets you the same Chromium-quality output without the pager duty.

FAQ

Is Puppeteer or Playwright better for PDF generation?

For pure PDF output they're roughly equivalent — both drive Chromium and expose a page.pdf() call. Playwright has a slightly nicer API and better cross-browser testing support if you need that; Puppeteer has a larger community and more Stack Overflow coverage for edge cases. Neither solves the underlying ops burden of running a browser in production.

Can pdfkit or pdf-lib render HTML and CSS?

No. Both build PDFs from drawing primitives (text, shapes, images) or by editing an existing PDF — neither has an HTML/CSS rendering engine. If your source is HTML you already have (an email template, a web page), you need a headless browser or a hosted API that wraps one.

Will a hosted PDF API work in a serverless function?

Yes, and it's one of the main reasons teams switch to one. Serverless platforms charge for execution time and often can't hold a Chromium process warm between invocations, so every cold Puppeteer launch is slow and expensive. A hosted API is a single HTTP call from your function with no browser to bundle or keep alive.

What does idempotencyKey actually protect against in Node.js?

It protects against double-rendering (and double-billing) when the same request fires twice — a retried fetch after a timeout, a queue message redelivered, a user double-clicking submit. Pass the same key for logically-the-same document and the API returns the original result instead of rendering again.

Related

Generate your first PDF in minutes.

Get an API key