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.

  • PDF
  • 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

unauth route

Puppeteer

headless Chromium instance

⚠ memory cost

Next.js Page

getServerSideProps fetches + parses data

@page Print

margins, counters, page-breaks, timestamp

PDF

pixel-accurate, matches Figma layout

✓ output

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

CSV reports baselinecomfortable
After PDF deploy700–900 MB
Large client datasetpod ceiling hit
pod died
~1 GB ceiling

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.