If you've worked with designers, you know the routine.
They hand you PDF templates: invoices, reports, certificates. Specific fonts, aligned columns, logos.
You look at it and think: "I could build this in HTML and CSS in 20 minutes."
So you do. Puppeteer. page.pdf(). Perfect output.
Then reality hits.
A client clicks "Download All" on 5,000 invoices and your server melts.
I optimized everything I could. Browser warmup, page reuse, connection pooling. Best I got was about 25 PDFs per second.
At some point you realize: you can't optimize around running a full browser engine to generate a static document.
So I tried something different
I'm a Bun user, and Bun is fast partly because it's built in Zig. I'd been wanting to learn Zig, and a PDF renderer felt like the perfect project: real problem, clear spec, and a chance to see what the language could actually do.
So I started building. Just a <Page> and a <Box>. If I could render that to a valid PDF, I had something.
Then I kept going. Styles, text wrapping, fonts with subsetting, images, QR codes. Each feature was a small, solvable problem. I paired with Claude, not to generate code blindly, but to work through the hard parts: the PDF spec, font parsing, AES encryption, layout edge cases.
The result
import { render } from "@slothpdf/render";
const template = `
<Page size="A4" margin="20mm">
<Box class="text-2xl font-bold mb-4">{name}</Box>
<Box class="text-sm text-gray-600">{description}</Box>
</Page>
`;
await Bun.write("invoice.pdf", render(template, {
name: "John",
description: "Hello World"
}));
No browser. No runtime overhead. Just template in, PDF out.
// Batch
for (const { index, buffer } of render(template, rows)) {
await Bun.write(`invoices/${index}.pdf`, buffer);
}
// Merge into one multi-page PDF
render(template, rows, { merge: true })
// ZIP archive
render(template, rows, { zip: true })
// AES-256 encryption
render(template, data, { password: "secret" })
All options stack. Not a full HTML/CSS engine. Intentionally limited to predictable layouts.
Benchmarks
Apple M4, single-threaded, 1,000 unique invoices with custom fonts and 3-10 line items per invoice:
| Tool | Speed |
|---|---|
| SlothPDF | 17,000 /sec |
| jsPDF | 7,750 /sec |
| Puppeteer | ~18 /sec |
Memory stays flat. 50,000 renders, no growth. 1,000 PDFs in ~60ms on a single thread.
Why it's fast
There's no trick. It's just fewer layers:
- No browser, no DOM, no CSS parser
- Fonts are parsed once and cached for the process lifetime
- Templates are parsed once, only data binding and layout run per render
- Memory is allocated in arenas that reset after each PDF, no GC
- The Zig engine writes PDF bytes directly, no intermediate format
Try it
bun add @slothpdf/render
- Playground (build templates in the browser)
- npm
- GitHub
Requires Bun 1.1+. macOS and Linux.
If you're hitting Puppeteer limits, this removes the bottleneck entirely. Curious what you're using for PDFs today.