Invoice PDFs that sometimes never finished rendering. That was the bug. It took far longer to find than to fix, and the cause was strange enough that I want to write it down.
The setup
I'm building Freveo, a workspace for freelancers: proposals, invoices, change orders, time tracking. Every invoice, contract and timesheet is rendered to PDF in headless Chromium, in a separate PDF service. The API runs on Bun.
The symptom
Renders sometimes never finished. I first saw it in CI, once the new change-order tests started running beside the invoice tests. Nothing failed with an error. A render just sat there until the test runner gave up.
Locally it was hard to trigger, which is the worst kind of bug to have.
The wrong fix
My first move was a longer test timeout. It was the obvious thing: slow CI, long PDF, give it more time.
It didn't help. The hang wasn't slow, it was permanent. I reverted the change, because a timeout that hides a hang is how you end up shipping one.
The pattern
Once I stopped guessing and started logging, the pattern was narrow:
- Only a Chromium launched after an earlier one had closed, in the same process, stalled.
- The first launch was always fine.
- The stall happened on one call:
setContentwith a large document, multiple megabytes of HTML. Small pages were fine. - It stalled below Puppeteer's own timeouts, so nothing threw. It simply waited.
That's why it only showed up when tests ran together: the invoice tests closed a browser, and the next suite launched a new one in the same Bun process.
The proof
"It seems to be the second launch" isn't a diagnosis, so I made a reproduction I could run on demand: a linux/amd64 container, the same large setContent, a first browser opened and closed, then a second one launched.
- Connected over the pipe: it hung on every run.
- Connected over a WebSocket: it never hung.
Same code, same page, same container. The only variable was how the process talked to Chromium. That was the point where I stopped suspecting my own code.
I'm not claiming a root cause deeper than that. I can tell you what reproduces and what doesn't. Why the pipe transport stalls on a large write in a second browser under Bun, I can't say with certainty.
Why it mattered in production
In CI this was an annoyance. In production it was worse.
The PDF service recycles browsers and kills renders that pass their deadline, and both relaunch Chromium inside a process that has already closed one. So a render could hang until the container's watchdog noticed and restarted the whole service. A customer waiting on an invoice PDF doesn't care about transports.
The fix
The fix was small: connect to Chromium over WebSocket instead of the pipe. In Puppeteer terms, that means not launching with pipe: true:
// Before: stalled on the second launch under Bun const browser = await puppeteer.launch({ pipe: true }); // After: default WebSocket connection const browser = await puppeteer.launch(); The container reproduction became a regression check, so if someone changes the transport later, it fails loudly instead of hanging quietly.
What I'd check first next time
- Does it only happen after a previous instance closed? Ordering bugs hide in "works on first run" code.
- Does it depend on payload size? A hang that only appears with large input points at buffering or transport, not logic.
- Can I change the transport and keep everything else identical? That single swap told me more than hours of staring at my own code.
- Reproduce in the same OS and architecture as production. The container was what made it deterministic.
- Be suspicious of timeouts as fixes. If a longer wait changes nothing, you don't have a slow problem.
One more thing
If you've hit something similar with Puppeteer or Playwright under Bun (or Node), I'd like to know whether the second-launch stall reproduces for you too. What's the strangest bug that only ever showed up in CI for you?
I'm building Freveo solo and writing up the bugs along the way.
