I replaced Puppeteer with a Zig library and went from 18 to 17,000 PDFs per second

javascript dev.to

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"
}));
Enter fullscreen mode Exit fullscreen mode

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" })
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.

Source: dev.to

arrow_back Back to Tutorials