DEV Community

Keenan Finkelstein
Keenan Finkelstein

Posted on

Why Your PDF Pipeline Is Slower Than It Needs To Be

Every backend engineer has been there. The client wants invoices. Reports. Certificates. Statements. "Just generate a PDF," they say, like it's a print() statement.

So you reach for the standard toolchain: render HTML with Jinja2, spin up a headless Chrome instance, call page.pdf(), and pray it doesn't OOM on the 500th document in the batch.

It works. Until it doesn't.

The headless browser tax

Here's what actually happens when you generate a PDF through a headless browser:

  1. Spawn a Chromium process (or connect to a pool)
  2. Create a new page context
  3. Load your HTML + CSS + assets
  4. Wait for fonts, images, layout
  5. Call the print-to-PDF API
  6. Serialize the PDF bytes
  7. Tear down the page context

For a single invoice, this takes 2-5 seconds. For a batch of 10,000 monthly statements, you're looking at hours of compute, gigabytes of RAM, and a deployment that needs its own dedicated infrastructure just to print documents.

The worst part? Chromium is rendering a full web page — JavaScript engine, DOM, CSSOM, layout tree, paint, composite — when all you need is "put these words in these positions and draw some lines."

What deterministic rendering actually means

A deterministic PDF renderer doesn't interpret your document as a web page. It reads a structured template, resolves the layout, and writes PDF primitives directly. No browser. No JavaScript engine. No intermediate rendering steps.

The difference in practice:

Metric Headless Browser Native Renderer
Time per document 2-5 seconds 50-200ms
Memory per document 200-500 MB 10-30 MB
Batch of 10,000 5-14 hours 8-30 minutes
Determinism No (race conditions) Yes (SHA256 reproducible)
Dependencies Chrome/Puppeteer None

That last row matters more than you think. "No dependencies" means your PDF generation works in a Lambda function, a Docker container, a CI pipeline, or a bare metal server with nothing installed. No Chrome binary to manage. No version mismatches. No sandboxing headaches.

The reproducibility problem nobody talks about

Generate the same invoice twice with a headless browser. Compare the bytes. They won't match.

Fonts render slightly differently across runs. Timestamps embed in metadata. Image compression isn't bitwise stable. This means you can't verify a document hasn't been tampered with by comparing hashes. You can't cache aggressively. You can't build audit trails that depend on document identity.

A deterministic renderer produces identical bytes for identical inputs. Always. This isn't academic — it's a compliance requirement in healthcare, finance, and legal document pipelines. If you're generating documents for regulated industries, non-determinism is a liability.

What this looks like in practice

I built Fullbleed to solve this problem. It's a Rust-native PDF rendering engine with Python bindings. Here's what generating a document looks like:

pip install fullbleed
fullbleed render invoice.html --output invoice.pdf
Enter fullscreen mode Exit fullscreen mode

Under the hood: Rust parses the HTML/CSS, resolves layout using its own engine (no browser), and writes PDF 1.7 directly. Python bindings release the GIL during Rust execution, so you can parallelize across cores in a batch job.

For batch rendering:

from fullbleed import render_batch

documents = [
    {"template": "invoice.html", "data": customer}
    for customer in customers
]

# Renders across all available cores via Rayon
results = render_batch(documents, workers="auto")
Enter fullscreen mode Exit fullscreen mode

10,000 invoices. Minutes, not hours. Deterministic output. No Chrome in sight.

When you should (and shouldn't) use a native renderer

Use a native renderer when:

  • You're generating documents in batch (invoices, statements, reports)
  • You need reproducible output for compliance or auditing
  • Your pipeline runs in constrained environments (Lambda, CI, edge)
  • Performance matters (sub-second per document)
  • You want zero external dependencies

Stick with headless Chrome when:

  • You're rendering arbitrary user-provided HTML/CSS/JS
  • You need pixel-perfect web page screenshots
  • Your templates use complex JavaScript interactions
  • You generate fewer than 10 documents per day and don't care about speed

Most backend engineers default to headless Chrome because it's what they know. But if your use case is structured document generation — templates with data — you're paying a massive performance and complexity tax for capabilities you don't need.

The takeaway

PDF generation is a solved problem that most teams solve badly. Not because they're incompetent, but because the obvious tools (wkhtmltopdf, Puppeteer, Playwright) optimize for generality over performance. When your actual requirement is "fill this template with this data and give me a PDF," a purpose-built renderer is 10-100x faster, uses a fraction of the memory, and produces deterministic output.

If you're building document pipelines and want to explore this approach, Fullbleed is open source (AGPLv3) and installs with pip install fullbleed. Commercial licenses are available for proprietary use.


I'm Keenan, a backend engineer specializing in Python, Rust, and document automation. I built Fullbleed because I got tired of managing Chromium clusters just to print invoices. If you're working on a document pipeline and want to talk architecture, find me on Upwork.

Top comments (0)