No. 02
Compliance Reporting Platform
The report looked perfect. Then we deployed it to a client with real data and watched the pod ceiling disappear.
- Puppeteer
- SSR
- Print CSS
- Role
- Frontend Engineer
- Team
- Compliance Engineering
- Timeframe
- 2024
The Brief
Compliance reports needed to go out to customers — formatted, branded, printable PDFs. The backend is Go. The obvious path was a Go PDF library or a template renderer. We looked at it and quickly ruled it out.
The design team had built detailed Figma layouts — multi-page reports with section headers, severity badges, data tables, coverage charts. Replicating that fidelity in a Go PDF template would've meant hand-coding every pixel of layout in a library that doesn't do CSS. That wasn't a week of work, it was a month, and it would break every time the design changed.
The Approach
We built it as a web page instead. The Go backend generates the report data, serializes it to JSON, creates a short-lived auth token, and hands both to Puppeteer. Puppeteer opens the Next.js report page — an unauthenticated route that accepts the token as a query param — and triggers a print.
- Unauth route with short-lived token: The report page is public but useless without the token. Token expires fast — just long enough for Puppeteer to load the page.
- data.json via SSR: getServerSideProps fetches the report JSON from the backend using the token, parses it, and passes the full typed structure to the page.
- 5 template types: Owner-wise, cloud-wise, individual owner, individual cloud, and a default framework view — same page, different render path based on TemplateType in the data.
- Ctrl+P simulation: Puppeteer calls page.pdf() which triggers the browser's print pipeline — @page margins, page-break rules, everything.
Report generation pipeline
Go Backend
generates report data
JSON + Token
short-lived auth, expires fast
Puppeteer
headless Chromium instance
Next.js Page
getServerSideProps fetches + parses data
@page Print
margins, counters, page-breaks, timestamp
pixel-accurate, matches Figma layout
Each request = one full Chromium instance. Cheap at low concurrency, fatal at scale.
For the preview experience — when a user opens the report in a browser before downloading — we used @media print to make it feel like a document viewer, not a raw web page. The report header is hidden on screen and only appears in print. A4 dimensions, box shadows stripped, table rows protected from page breaks.
Two Views, One Page
Same HTML — two stylesheets
Browser preview
- ·box-shadow on A4 cards
- ·report-header: opacity 0
- ·scrollable layout
- ·@page rules ignored
@media print · PDF
- ·box-shadow: none
- ·report-header: opacity 1
- ·A4 size, 0 margin
- ·timestamp in @bottom-right
- ·page-break-inside: avoid on rows
The header is invisible on screen so it doesn't clutter the preview — Puppeteer reveals it only in print.
One detail that's easy to miss: the same page serves two different audiences. A user previewing the report in their browser sees a clean document viewer — scrollable, white background, subtle card shadow. Puppeteer generating the PDF sees the print stylesheet — no shadow, exact A4 margins, auto section counters via CSS, page numbers injected via @page bottom-right.
The generated timestamp in the PDF footer — 'Generated on Jun 12, 2024, 02:30 PM IST' — is injected as an inline <style> tag from the server with the actual time baked in. No client-side JS, no hydration gap. It's there when Puppeteer takes the snapshot.
Then It Hit Prod
In development and early testing, it worked well. Report generation was fast, output looked exactly like the Figma designs, and the UX of previewing before downloading was noticeably better than anything we'd shipped before.
Before this feature, the report-generation pod sat comfortably at around 250MB. After the first prod deploy — even with normal-sized reports — it jumped to 700–900MB. That was concerning, but the pod held.
- Pod baseline (CSV reports)
- ~250MB
- After PDF feature deploy
- 700–900MB
- Pod memory ceiling
- ~1GB
Report-generation pod memory
Then we deployed to a customer environment with a large dataset. Puppeteer spun up, loaded the report page, started rendering — and the pod ran out of memory mid-generation. The process died, the pod restarted, and every metrics dashboard turned red. We reverted the build the same day.
The fix wasn't complicated in hindsight: headless rendering at scale belongs on the client, not inside a backend pod with a 1GB ceiling. When the user's browser renders the page and triggers print, the memory cost is theirs. When the server does it, the memory cost is yours — multiplied by every concurrent request.
Where It Landed
Server-side PDF generation is off. The compliance report page still exists and still works — users can open it in a browser, preview it, and print to PDF themselves. The Figma designs, the dual-render pattern, the five template types — all of that is intact.
Server-side report generation now handles only CSV exports, the same as before. A proper fix — either a dedicated PDF microservice with tighter resource controls, or moving to a client-triggered download — is on the backlog.