Generating PDFs in Node.js: ten approaches compared

Puppeteer, Playwright, Gotenberg, PDFKit, pdf-lib, react-pdf, jsPDF, Typst, LibreOffice and hosted APIs. How each one works, what it weighs, and how it behaves with Hebrew and RTL text.

Node.jsPDF

Every system that sends a document to a customer arrives at the same point: it needs a PDF, and the PDF has to look right. An invoice, a report, a ticket, a contract. Node offers roughly ten ways to do it, and they differ on three things.

The first is who draws the page. Either a browser runs HTML and CSS and prints the result, or a library draws text and lines at coordinates. The first gives you familiar layout tooling at the cost of 300MB of Chromium in every container. The second weighs a few megabytes and asks you to compute the position of every line.

The second is where it runs. Lambda with 250MB unpacked, a Docker container with no such limit, or one small Node process that also serves HTTP requests.

The third one decides it for Hebrew and Arabic: bidirectional text needs two separate things, and they are easy to confuse. Shaping picks the correct glyph forms out of the font. Bidi, the UAX#9 algorithm, decides what order to place them in when a line mixes Hebrew, digits and Latin. A browser does both. Most PDF libraries do only the first, and some do not even manage that.

Puppeteer

The common choice. Puppeteer runs Chromium, loads HTML, and asks it for the same print function that sits behind Ctrl+P.

import puppeteer from 'puppeteer';

const browser = await puppeteer.launch({ args: ['--no-sandbox'] });
const page = await browser.newPage();

await page.setContent(html, { waitUntil: 'networkidle0' });
const pdf = await page.pdf({
  format: 'A4',
  printBackground: true,
  margin: { top: '18mm', bottom: '18mm', left: '15mm', right: '15mm' },
  displayHeaderFooter: true,
  headerTemplate: '<div></div>',
  footerTemplate: '<div style="font-size:9px;width:100%;text-align:center">' +
    '<span class="pageNumber"></span> / <span class="totalPages"></span></div>',
});

await browser.close();

Everything you know about CSS applies: Flexbox, Grid, @media print, page-break-inside: avoid on a table row, position: fixed for a running header, direction: rtl on the root. Hebrew works out of the box, mixed digits included, because Chromium's text engine runs full bidi.

The costs are well known. Installation pulls Chromium, and a small Docker image stops being small. Every conversion opens a page, which means a renderer process and memory. Running as root in Docker pushes people to --no-sandbox, and the moment your HTML contains anything a user supplied, that flag matters more than it looks.

Two recurring mistakes. First, waitUntil: 'networkidle0' on HTML that pulls a font from Google Fonts: if the container has no outbound network, the page waits for the timeout and then prints in a fallback font. Ship fonts as local files or inline them as base64 in @font-face. Second, launching a browser per request. Keep one instance alive and open pages from it, or better, isolated contexts:

const context = await browser.createBrowserContext();
const page = await context.newPage();
// ...
await context.close();

Playwright

The same approach with a more grown-up toolchain. playwright install --with-deps chromium also installs the system libraries missing from slim Debian images, which saves the guessing round of libnss3, libatk and libgbm.

import { chromium } from 'playwright';

const browser = await chromium.launch();
const page = await browser.newPage();

await page.goto(`${APP_URL}/invoices/1042/print`, { waitUntil: 'networkidle' });
await page.emulateMedia({ media: 'print' });
const pdf = await page.pdf({ format: 'A4', printBackground: true });

await browser.close();

page.pdf() is Chromium-only in Playwright too. If the document is built from an authenticated page, browser.newContext({ storageState }) injects session cookies instead of driving a login screen on every run. There is also an official Docker image with the browsers and fonts already inside, which makes the difference from Puppeteer an operational one rather than an API one.

Gotenberg

Instead of running a browser inside your Node process, run it as a separate service and post files to it over HTTP. Gotenberg wraps Chromium and LibreOffice.

const form = new FormData();
form.append('files', new File([html], 'index.html', { type: 'text/html' }));
form.append('files', new File([logo], 'logo.png'));
form.append('paperWidth', '8.27');
form.append('paperHeight', '11.7');

const res = await fetch('http://gotenberg:3000/forms/chromium/convert/html', {
  method: 'POST',
  body: form,
});
const pdf = Buffer.from(await res.arrayBuffer());

What that buys: the application stays light, the browser gets its own container and its own memory limit, and a crashed conversion does not take the API server down with it. You can add conversion capacity without touching the app. The price is one more piece of infrastructure to monitor, plus a network round trip per document, which shows up once documents get heavy.

Fonts live in the Gotenberg image, not in yours. An image built without a Hebrew font returns boxes, and that is the first bug everyone meets here.

PDFKit

The other end of the map. No browser, no HTML. You draw.

import PDFDocument from 'pdfkit';

const doc = new PDFDocument({ size: 'A4', margin: 50 });
const chunks = [];
doc.on('data', (c) => chunks.push(c));

doc.registerFont('body', 'assets/Rubik-Regular.ttf');
doc.font('body').fontSize(20).text('Tax invoice 1042');
doc.moveDown().fontSize(11).text('Total due: 1,240.00');

doc.end();
await new Promise((resolve) => doc.on('end', resolve));
const pdf = Buffer.concat(chunks);

The library is small, runs anywhere, and produces a document in single-digit milliseconds. It fits when the layout is fixed and known in advance, a shipping label or a fixed-size receipt.

Hebrew is where it breaks. PDFKit uses fontkit and therefore reads OpenType fonts, but it runs no bidirectional algorithm. A line mixing Hebrew, digits and punctuation comes out in the wrong order, and patching it by reversing strings falls apart as soon as a number or a Latin word joins the line. If you must, run the text through bidi-js before drawing it, but every text box is now code you own.

The second cost is layout: a table with variable column widths, a row that splits across pages, a header that repeats on the next page. All of it is written by hand.

pdf-lib

pdf-lib is not a layout engine. It reads and writes the file structure itself, which is exactly its strength: it is the one entry here that works well on a PDF that already exists.

import { PDFDocument } from 'pdf-lib';

const pdf = await PDFDocument.load(templateBytes);
const form = pdf.getForm();
form.getTextField('customer').setText('Example Ltd');
form.getTextField('total').setText('1,240.00');
form.flatten();

const merged = await PDFDocument.create();
for (const src of [pdf, await PDFDocument.load(termsBytes)]) {
  const pages = await merged.copyPages(src, src.getPageIndices());
  pages.forEach((p) => merged.addPage(p));
}

const bytes = await merged.save();

Form filling, merging, splitting, rotating, stamping an existing page, editing metadata. No native dependencies and no browser, and it runs in the browser as well. A non-standard font needs @pdf-lib/fontkit registered explicitly, and the Hebrew limitation returns: no bidi, so character order is your problem.

The combination that works in practice is Chromium producing the page and pdf-lib appending an annex or a signature page to it. The two libraries do different jobs and do not compete.

react-pdf

If the UI is React, @react-pdf/renderer lets you describe a document in the same language, with its own layout engine that understands Flexbox.

import { Document, Page, Text, View, Font, renderToBuffer } from '@react-pdf/renderer';

Font.register({ family: 'Rubik', src: 'assets/Rubik-Regular.ttf' });

const Invoice = ({ order }) => (
  <Document>
    <Page size="A4" style={{ fontFamily: 'Rubik', padding: 40 }}>
      <Text style={{ fontSize: 20 }}>Tax invoice {order.id}</Text>
      {order.lines.map((line) => (
        <View key={line.id} style={{ flexDirection: 'row', justifyContent: 'space-between' }}>
          <Text>{line.name}</Text>
          <Text>{line.total}</Text>
        </View>
      ))}
    </Page>
  </Document>
);

const pdf = await renderToBuffer(<Invoice order={order} />);

The win is components: the same <InvoiceRow /> on screen can wrap the same data model. No Chromium, and deterministic output.

That win runs out quickly when you try to reproduce an existing design. The supported CSS subset is small, there is no Grid, position is limited, and page-break control amounts to break and wrap. RTL support exists but is partial, and a line mixing Hebrew and Latin needs to be checked by eye rather than assumed. Server-side JSX also adds a build step you did not have before.

jsPDF

Written for the browser, runs in Node too. It fits when generation has to happen on the user's machine, without sending the data to a server at all.

import { jsPDF } from 'jspdf';
import autoTable from 'jspdf-autotable';

const doc = new jsPDF({ unit: 'mm', format: 'a4' });
doc.addFileToVFS('Rubik.ttf', rubikBase64);
doc.addFont('Rubik.ttf', 'Rubik', 'normal');
doc.setFont('Rubik').setR2L(true);

doc.text('Sales report', 190, 20, { align: 'right' });
autoTable(doc, { head: [['Amount', 'Customer']], body: rows, styles: { font: 'Rubik' } });

const blob = doc.output('blob');

setR2L(true) reverses character order in a line, which handles Hebrew-only text. Add a number or a Latin word in the middle and the result is wrong again, because reversing is not bidi. Adding a font means converting it to base64 up front, which inflates the JS bundle. jspdf-autotable solves tables and is the reason most people stay.

Simple rule: jsPDF earns its place when generation must happen client-side. On a server there are better options.

Typst, LaTeX and Pandoc

When the document is long, structured and typographically demanding, a real typesetter does better work than a browser. Node only prepares an input file and runs a process.

import { execFile } from 'node:child_process';
import { promisify } from 'node:util';

await promisify(execFile)('typst', [
  'compile',
  'templates/report.typ',
  '/tmp/report.pdf',
  '--input', `total=${total}`,
]);

Typst is fast, the language is readable, and compilation takes a fraction of a second. LaTeX with XeLaTeX or LuaLaTeX gives full typographic control, Hebrew included through polyglossia, at the cost of a multi-gigabyte install and a language nobody on the team wants to maintain. Pandoc fits when the source is Markdown and the output is needed in several formats.

All three run an external process. If the template takes user input, validate it: a typesetter is an execution engine, and LaTeX in particular can read and write files unless you lock it down.

LibreOffice and DOCX templates

An easy scenario to miss: the accountant or the legal team owns a Word template and wants to keep editing it. Instead of rewriting it in HTML, fill it and convert.

import Docxtemplater from 'docxtemplater';
import PizZip from 'pizzip';

const doc = new Docxtemplater(new PizZip(templateBuffer), { linebreaks: true });
doc.render({ customer: 'Example Ltd', total: '1,240.00' });
const docx = doc.getZip().generate({ type: 'nodebuffer' });

// soffice --headless --convert-to pdf --outdir /tmp /tmp/filled.docx

The template stays with whoever wrote it, and the design survives. The price is LibreOffice in a container, a conversion measured in seconds rather than milliseconds, and a process that does not always exit cleanly and needs a timeout and a kill. Gotenberg exposes exactly this route as an endpoint, which is usually the sane way to run it.

Hosted APIs

DocRaptor, PDFShift, Api2Pdf, Browserless, PDFMonkey. You send HTML or a URL and get a file back.

const res = await fetch('https://api.example-pdf.com/v1/convert', {
  method: 'POST',
  headers: { 'content-type': 'application/json', authorization: `Bearer ${KEY}` },
  body: JSON.stringify({ source: html, format: 'A4' }),
});

This takes the whole browser-in-production subject off the team: no image to maintain, no memory leaks, no missing fonts. DocRaptor runs PrinceXML, which handles page breaks and repeating table headers better than Chromium does.

In exchange, every document costs money and passes through a third-party server. For a document carrying a customer name, amounts and a tax ID, that is a privacy decision as much as a budget one, and it belongs in your data processing agreement.

Comparison

Approach Dependency Hebrew and RTL Fits when
Puppeteer Chromium, around 300MB Full, mixed lines included The document already exists as HTML
Playwright Chromium, official image Full Same, when Docker matters
Gotenberg Separate container Full, if the image has the font High volume, service isolation
PDFKit Library only Shaping only, no bidi Fixed layout, label or receipt
pdf-lib Library only No bidi Editing, merging, filling forms
react-pdf Library and a build step Partial, needs checking React code shared by screen and document
jsPDF Library only setR2L only Browser-side generation, no server
Typst or LaTeX External compiler Good, once configured Long reports, typography
LibreOffice Heavy package Good A Word template someone else maintains
Hosted API Account and connectivity Full Getting the subject off the team

What actually breaks with Hebrew

The most common failure is not code, it is a missing font. A node:22-slim image ships with almost none, and Chromium that cannot find a Hebrew font draws boxes. fonts-noto-core and fonts-noto-hebrew fix it, or just copy your own TTF into the image:

COPY assets/Rubik-Regular.ttf /usr/share/fonts/truetype/rubik/
RUN fc-cache -f

The second failure is order. direction: rtl on an HTML element turns bidi on and handles a line like Total 1,240.00 ILS inside Hebrew text. Drawing libraries have no such switch, and anyone reaching for text.split('').reverse().join('') will find the amount reversed along with the words.

The third is subtle: niqqud, geresh marks and punctuation at a line edge. Test on real text from your system, not on a hello-world string.

Running it in production

Five things that matter more than the library you pick.

Run the conversion outside the request cycle. A job queue with a separate worker stops one heavy document from occupying the server, and lets a failure retry without the user seeing an error.

Cap memory and time. --max-old-space-size on Node does not constrain the browser process, which is the one that grows; the limit belongs at the container level, and every conversion needs a timeout that closes the page even when the conversion failed.

Reuse one browser instance rather than one per request, but restart it every few hundred conversions. Memory leaks in a long-lived Chromium are a fact, not a theory.

On Lambda, use @sparticuz/chromium with puppeteer-core. The full puppeteer package does not fit the size limit, and /tmp is the only writable path.

Watch output size. A sudden fivefold jump in file size usually means an image went in at full resolution instead of a thumbnail, and users will feel that before your monitoring does.

Choosing

If the document already exists as a page in the app, Playwright or Puppeteer, moving to Gotenberg once volume justifies a separate service. If the layout is small and fixed and you want no browser, PDFKit. If you need to touch an existing file, pdf-lib, whatever produced it. If the template belongs to someone who works in Word, LibreOffice. And if nobody on the team wants to own a browser in production, a hosted service, provided the contents of the document are allowed to leave.