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();
}
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
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 ~4×.
- 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
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);
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 });
How it works (it really is this simple)
-
cachingFontkitmemoizesfontkit.create()per font buffer. It's aWeakMapkeyed by theUint8Arrayyou pass in, so the expensive TTF/OTF parse happens once per font instead of once per document. Because it's aWeakMap, 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. -
bulkGenerateis the loop with that cache pre-wired: create doc → embed cached fonts → yourdrawcallback → save. -
makeTemplateClonerloads your finished template once and produces copies viacopyPages, 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.
cachingFontkitis the same fontkit interface.bulkGenerate/makeTemplateClonerare 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)