Can we generate PDF invoices without a headless browser?
Author: Hana Ito · Date: 2026-09-17
Question
Can Copperleaf Labs render customer invoices as PDF, matching the current layout, without running headless Chromium in the billing service, at under 200 ms per invoice?
Time box
Three days. If no option met the bar by then, the plan was to recommend keeping Chromium but moving it into its own worker pool.
Background
The billing service runs headless Chromium to print invoices. It uses about 400 MB of memory per pod, causes most of our out-of-memory restarts, and blocks the move to smaller nodes planned for Q4. The platform team needs an answer before sizing those nodes on 1 October.
Approaches tried
| Approach | Result | Evidence |
|---|---|---|
| pdf-lib, drawing the layout by hand | Works, 35 ms per invoice, but 600 lines of positioning code | Prototype branch spike/pdf-lib, benchmark of 1,000 invoices |
| React-PDF with our existing components | Works, 120 ms per invoice, layout matches within 2 px | Branch spike/react-pdf, visual diff of 50 real invoices |
| Typst via a WebAssembly build | Fast (60 ms) but fonts and right-to-left text failed | Branch spike/typst, 9 of 50 invoices rendered wrong |
| Chromium in a separate worker pool | Works, unchanged output, still 400 MB per worker | Load test notes, 2026-09-16 |
Findings
React-PDF reproduces the invoice layout closely, handles our three fonts and Arabic invoices correctly, and keeps memory under 90 MB per pod. pdf-lib is faster but every layout change would need hand-tuned coordinates, which finance requests about once a month. Typst is promising but not ready for right-to-left text. Moving Chromium to a worker pool solves the memory problem for the billing pods but adds a service to operate.
One dead end worth recording: React-PDF's default font loading fetches from the network on every render. Registering fonts once at startup cut render time from 480 ms to 120 ms.
Recommendation
Replace Chromium with React-PDF for invoices, reusing our existing invoice components. Remove Chromium from the billing image once the switch has run in production for two weeks.