Skip to content
All field notes

3 min read

PDF Generation, Five Engines Later

htmldoc, TCPDF, a Go service, Dompdf, then Chromium — and a 142-page catalog that went from 53.6 seconds to 1.9

  • PDF
  • Laravel
  • Performance
  • Chromium
  • Infrastructure

Sale barns run on paper: printed catalogs, contracts, settlement sheets. Which means CattleUSA runs on PDF generation, and over the years we have shipped it five different ways. This is the arc, including the measurement that finally ended the argument.

Engines one through four

htmldoc came first — an old C tool that turns HTML into PDF, installed from Alpine Linux's testing package repository. It worked until 2025, when the package moved between Alpine repos and our image builds broke. A dependency you install from a distro's testing channel is a dependency with no service-level anything; we were reminded of that at build time, which is at least better than at runtime.

TCPDF replaced it that summer — a pure-PHP generator, no system binary to lose. But TCPDF wants you to program your document (coordinates, cells, manual pagination), and catalog layouts are design artifacts that change with the season. Four days after the TCPDF port landed, we tried a different shape entirely: a separate Go printing service, moving the CPU-heavy rendering out of the PHP request path. That solved the load problem and created an operational one — another service to deploy, monitor, and version against templates that lived in a different repo.

By January this year we'd pulled rendering back in-process on Dompdf, with the catalog rebuilt as HTML templates plus a real stylesheet. That was the right authoring decision — the catalog became something you could restyle with CSS instead of re-coding — and the port came with real wins we measured at the time: barn logos moved from per-request HTTP fetches to local disk, database eager-loading scoped to just the requested lots, and a content-hash disk cache that took a 51-lot, 17-page catalog from about 1.5 seconds cold to 12 milliseconds cached.

The catalog that blew the limits

Then a barn built a 400-lot catalog — 142 pages — and Dompdf hit a wall that caching can't fix: its layout cost grows super-linearly with page count. The render took 53.6 seconds and 568 MB of memory, sailing past both our 30-second execution limit and the container's 512 MB ceiling. Not a slow render; a structurally impossible one.

The fix was to stop asking a PHP library to be a browser and ask a browser instead. We added a Gotenberg sidecar — headless Chromium behind a small HTTP API — and pointed the same HTML templates at it. Chromium's pagination cost is linear, and the numbers were absurd: the same 142-page catalog rendered in 1.9 seconds producing a 729 KB file, visually identical to Dompdf's output because both were rendering the same HTML and CSS.

Two implementation details I'd keep in any version of this system:

  • Dompdf stayed as an automatic fallback. If the sidecar is down, rendering degrades to in-process instead of failing — slower beats absent for a working document pipeline.
  • The timing logs record which engine rendered each document. When someone asks why a catalog was slow last Tuesday, the answer is in the log line, not in a guess about which path ran.

What five engines taught me

Author in HTML and CSS, always. Every engine change since the move to HTML templates has been a rendering swap, not a rewrite. The four-engine churn before that was expensive precisely because each engine imposed its own authoring model.

The rendering engine is an implementation detail — treat it like one. Behind an interface, with a fallback, with instrumentation. We didn't get that right until engine five, and the moment we had it, changing engines stopped being a project and became a config decision.

Measure the pathological case, not the demo. Every engine handled the 17-page catalog fine. The 142-page catalog is where they diverged by a factor of 28 in time and, more importantly, between "works" and "cannot complete." Your biggest customer's document is the benchmark that matters.