Why our PDF API uses two engines
A single-engine PDF API forces a trade-off between speed and capability. Splitting the engine into a Go-native fast path and a Chromium fallback removes the trade-off for the most common shapes. Here is how we route, what we measured, and what we got wrong.
Picking a PDF engine is a one-way decision. Once your customers depend on a feature only your engine supports, switching is expensive. So most PDF APIs pick one engine at the start and live with its trade-offs forever.
We picked two. Here is how that decision works in practice.
The trade-off neither engine wins
A PDF API has to make four customers happy on every request:
- Receipt-issuing customers (e-commerce, SaaS billing). Simple shapes, predictable layout, hundreds or thousands per minute. They want low latency.
- Report-generating customers (analytics, healthcare, finance). Charts, complex tables, multi-page layouts. They want fidelity to a designer's mockup.
- Multilingual customers. Hindi, Bengali, Tamil, Arabic, Hebrew, Thai, CJK. They want text shaping that matches what their browser does.
- Regulated customers. E-invoicing where the structured XML matters more than the visual layout. They want correctness.
A single-engine API has to make trade-offs among these.
A Chromium-only engine wins on report fidelity, multilingual rendering, and structured XML, because Chromium uses HarfBuzz for text shaping and accepts the same HTML/CSS your designer wrote. It loses on receipts. Launching a browser instance, navigating to a data URL, waiting for the load event, rasterizing, and serializing the PDF takes 200ms to 800ms. For a receipt that is 99% boilerplate, you have spent that latency on the engine, not on the document.
A pure-Go engine (gofpdf, pdfcpu, or our internal pkg/pdf/native_generator.go) wins on receipts. The fast path prints a 1-page receipt in under 100ms because there is no browser to launch. It loses on reports and on anything multilingual, because reimplementing HarfBuzz text shaping in Go is its own multi-year project.
If you build a PDF API on either alone, you make one set of customers unhappy.
The router
Two engines and a router is conceptually simple. The router takes the input HTML and decides which engine handles it. The router has to be:
- Cheap. It runs on every request. If it costs 50ms, you have eaten half the fast path's budget.
- Deterministic. Given the same input, it always picks the same engine. Customers who depend on the fast path's speed cannot have a request randomly land on the slow path.
- Conservative. When in doubt, pick Chromium. The cost of routing a simple document to Chromium is a slower response. The cost of routing a complex document to the fast path is a broken PDF. The first is recoverable.
Our router is in pkg/pdf/engine_router.go. The logic, condensed:
func (r *Router) Route(html string) RouteDecision {
// 1. Script tags require a JS runtime. Chromium only.
if hasScriptTags(html) {
return RouteDecision{Engine: EngineChromium, Reason: "<script> tags"}
}
// 2. Modern CSS layout features the fast path does not implement.
if hasComplexLayout(html) {
return RouteDecision{Engine: EngineChromium, Reason: "complex layout"}
}
// 3. Webfonts require fetch + shape. Chromium only.
if hasWebFonts(html) {
return RouteDecision{Engine: EngineChromium, Reason: "webfont @font-face"}
}
// 4. Non-Latin scripts (CJK, Indic, Arabic, etc.). Chromium only.
if hasComplexScript(html) {
return RouteDecision{Engine: EngineChromium, Reason: "complex script"}
}
// Default: the fast path can handle this.
return RouteDecision{Engine: EngineNative, Reason: "simple HTML"}
}
Each check is a regex against the input HTML. The total cost is under 1ms even on multi-megabyte input. The router never parses the HTML into a DOM. If it cannot tell whether a feature is present from a regex, it assumes it is, and routes to Chromium.
The router is wrong sometimes. It is wrong toward Chromium, which is the recoverable failure mode.
What we measured
We ran the router against six months of real production traffic, anonymized. The traffic mix:
- 63% receipts and simple invoices. No webfonts, no complex CSS, no scripts, ASCII or Latin-1 text. Routed to the fast path.
- 24% reports and rich documents. Webfonts, Grid layouts, sometimes Tailwind compiled inline. Routed to Chromium.
- 8% multilingual invoices. Hindi, Arabic, Hebrew, Thai, or CJK text in line items. Routed to Chromium.
- 5% playground experiments. Hard to classify; some had scripts, some had unusual CSS. Routed to Chromium.
Median latency, per engine:
- Fast path (Go-native): 47ms p50, 92ms p99.
- Chromium: 380ms p50, 1.4s p99.
The fast path takes 8% of the wall-clock time of the Chromium path for the workload it can handle. For the 63% of traffic that lands on it, that is real money in latency-sensitive applications.
What we got wrong
The router started as a single check: "does the HTML contain <script>, @font-face, display: grid, or any non-ASCII character?" That was too aggressive. It routed any document with a single non-ASCII character (a curly quote, a non-breaking space, a registered trademark symbol) to Chromium.
The fix was the hasComplexScript check. We classify non-Latin scripts by Unicode block, not by "non-ASCII or not." Latin-1 supplement, Latin Extended-A, Latin Extended-B, and punctuation blocks (curly quotes, em dashes) stay on the fast path. Devanagari, Bengali, Tamil, Arabic, Hebrew, Thai, Khmer, Hiragana, Katakana, CJK Unified Ideographs route to Chromium.
The other thing we got wrong was assuming Chromium would handle async rendering correctly. It does not, by default. The headless browser fires the load event when the initial HTML and synchronously-loaded resources are ready. If your document fetches data with JavaScript and renders after that, the PDF capture happens before the render completes. We shipped delay_ms and wait_for_selector API options in May to give callers explicit control, and the rendering is reliable for the documents we promised it would handle.
Should you do this?
Probably not.
The router-plus-two-engines architecture pays off when your traffic has both shape regimes and your latency-sensitive customers leave if you do not serve them in under 100ms. If you do not have those constraints, pick the engine that fits your dominant traffic shape and move on. Maintaining two engines is real ongoing work: when the Chromium pool drops a connection, when pdfcpu's PDF/A pipeline changes, when the fast path needs a new typography fix, the cost is two debug sessions and two release notes.
The reason we did it is that we wanted to be both fast and complete from the start. A single-engine API forces customers into one bucket or the other. We did not want to lose either bucket.
FAQ
What is the cost of running both engines?
The Go binary is 74 MB. The Chromium pool keeps a small set of warm browser instances; idle memory is roughly 200 MB per warm instance. We run six warm instances on a 4-core machine. The fast path adds negligible memory because it shares the Go runtime.
Can a customer force the engine?
Yes. The API accepts engine: "native" or engine: "chromium". Most customers never use it; the router gets it right almost always. Power users who want the fast path's speed and accept the loss of webfonts override the router and pass simple HTML directly.
Have you considered a third engine?
We have considered adding a Prince XML fallback for typography-heavy documents (journals, books, complex tables with footnote layout). It would be a third route in the router and a third licensing cost. We have not done it because no customer has asked for that fidelity yet. The day a customer does is the day we revisit.
LightningPDF
Building fast, reliable PDF generation tools for developers.