DEV Community

Cover image for Stop re-parsing your font 100,000 times: faster bulk PDF generation with pdf-lib
Jeet Dhandha
Jeet Dhandha

Posted on • Originally published at github.com

Stop re-parsing your font 100,000 times: faster bulk PDF generation with pdf-lib

The one line hiding in every pdf-lib batch job

If you generate PDFs at scale with pdf-lib — invoices, tickets, certificates, statements — you have almost certainly written a loop like this:

for (const invoice of invoices) {
  const doc = await PDFDocument.create();
  doc.registerFontkit(fontkit);
  const font = await doc.embedFont(fontBytes, { subset: true });
  const page = doc.addPage();
  page.drawText(`Invoice #${invoice.id}`, { x: 50, y: 700, size: 24, font });
  await doc.save();
}
Enter fullscreen mode Exit fullscreen mode

It looks fine. It is not fine. embedFont() calls fontkit.create(bytes) every single time it runs. Parsing a TrueType font is not free — it walks the glyph tables, the cmap, the hmtx metrics, the whole thing. So if you render 100,000 invoices from the same TTF, you parse that identical font file 100,000 times. You are doing the exact same expensive work, throwing the result away, and doing it again on the next iteration.

I hit this while generating a few hundred thousand documents from one corporate font, watched the profiler light up inside fontkit, and realized the fix is almost embarrassingly small. So I packaged it: pdf-lib-bulk — a tiny, zero-dependency helper that parses your font once and reuses it across the whole batch.

npm install @libs-jd/pdf-lib-bulk pdf-lib @pdf-lib/fontkit
Enter fullscreen mode Exit fullscreen mode

The numbers

100 one-page PDFs, one custom TTF, Apple M1. Reproduce with bun run bench/bench.ts in the repo.

Workload (100 PDFs, custom TTF) pdf-lib naive pdf-lib-bulk Speedup
Variable text per document (bulkGenerate) 1,399 ms 347 ms 4.0×
Identical documents (makeTemplateCloner) 1,515 ms 48 ms 31×

Two very different wins, because there are two very different situations:

  • Every document is different (invoices, letters, tickets — same font, different text). The font parse is the redundant part. Cache it and you get ~.
  • Every document is identical (blank forms, certificates you fill in later, a fixed cover page). Here the entire render is redundant, not just the font. Build the document once and clone the bytes, and you get ~31× — and it climbs higher the simpler each document is.

The variable-content number is stable; the template-clone number scales with how much work you're skipping, so on very simple documents I've seen it well past 31×. The table above uses the conservative published figure.

Three tools, pick by your situation

1. Variable content per document — bulkGenerate

This is the common case: a batch where each PDF has different text but shares fonts. bulkGenerate is a generation loop with the font cache pre-wired. You provide your fonts and a draw callback; it hands you a fresh document and the already-embedded fonts for each item.

import fontkit from "@pdf-lib/fontkit";
import { bulkGenerate } from "@libs-jd/pdf-lib-bulk";

const fontBytes = await fs.promises.readFile("Inter-Regular.ttf");

const pdfs = await bulkGenerate(invoices, {
  fontkit,                       // parsed once, reused for every document
  fonts: { body: fontBytes },
  draw({ doc, fonts, item }) {
    const page = doc.addPage();
    page.drawText(`Invoice #${item.id}${item.total}`, {
      x: 50, y: 700, size: 24, font: fonts.body,
    });
  },
});
// pdfs: Uint8Array[] — one standalone PDF per invoice
Enter fullscreen mode Exit fullscreen mode

Every output is a complete, standalone PDF with the font properly embedded and subset. Nothing is shared between the files themselves — only the parse step is shared, in memory, during generation.

2. Identical documents — makeTemplateCloner

When every output is the same bytes — a blank form, a certificate template, a fixed handout — you shouldn't be re-running layout, font embedding, or drawing at all. Build the document once, however you like, then clone it:

import { makeTemplateCloner } from "@libs-jd/pdf-lib-bulk";

// build the document once (fonts embedded once, layout once)
const templateBytes = await buildCertificateTemplate();

const cloner = await makeTemplateCloner(templateBytes);
const pdfs = await cloner.cloneMany(10_000);
Enter fullscreen mode Exit fullscreen mode

Under the hood this uses copyPages to duplicate the finished pages, skipping layout, font parsing, and font embedding entirely. That's why it's an order of magnitude faster than the naive rebuild-per-document loop.

3. Just the font cache — cachingFontkit

Maybe you have your own carefully tuned generation loop and don't want to hand it over. Fine — take just the cache and drop it in. It's the same fontkit API, so nothing else in your code changes:

import fontkit from "@pdf-lib/fontkit";
import { cachingFontkit } from "@libs-jd/pdf-lib-bulk";

const fk = cachingFontkit(fontkit);
// ...
doc.registerFontkit(fk);                     // same API, parse happens once
const font = await doc.embedFont(fontBytes, { subset: true });
Enter fullscreen mode Exit fullscreen mode

How it works (it really is this simple)

  • cachingFontkit memoizes fontkit.create() per font buffer. It's a WeakMap keyed by the Uint8Array you pass in, so the expensive TTF/OTF parse happens once per font instead of once per document. Because it's a WeakMap, there's no leak to manage — when your font buffer is gone, so is the cache entry. Fonts are still embedded and subset per document, so every output PDF stays standalone.
  • bulkGenerate is the loop with that cache pre-wired: create doc → embed cached fonts → your draw callback → save.
  • makeTemplateCloner loads your finished template once and produces copies via copyPages, skipping the redundant work entirely.

No magic, no clever tricks — just not throwing away the font parse on every iteration.

Why a separate package instead of a PR to pdf-lib?

Caching inside embedFont by default would change pdf-lib's memory profile for everyone, including people who embed a font exactly once. The redundant-parse problem only exists in bulk loops, so the right place to fix it is a thin bulk-oriented layer on top — opt-in, no behavior change for single-document users. pdf-lib stays exactly as it is; you reach for this only when you're generating many documents.

The details that matter

  • Zero runtime dependencies. It wraps pdf-lib and fontkit, which you already have. Nothing else comes along for the ride.
  • Drop-in. cachingFontkit is the same fontkit interface. bulkGenerate/makeTemplateCloner are small, focused functions.
  • TypeScript, fully typed.
  • MIT licensed.

If your service renders PDFs in batches, this is close to free performance — install it, swap your loop for bulkGenerate (or take just cachingFontkit), and stop paying for the same font parse a hundred thousand times.

Repo (benchmark + source): https://github.com/jeet-dhandha/pdf-lib-bulk
npm: npm install @libs-jd/pdf-lib-bulk

If it saves your batch job some hours, a ⭐ on the repo helps other people find it. Issues and PRs welcome.

Top comments (0)