At a previous company, users needed to download PDF reports containing candidate verification data — for compliance, auditing, and record-keeping. The PDFs had to be branded per client, support multi-page layouts, and generate reliably within 60 seconds.
This is how we built it.
The problem
We needed a system that could:
- Generate branded, multi-page PDF documents on demand
- Support client-specific colours, fonts, logos, and configurable sections
- Handle complex layouts (headers/footers on every page, activity timelines, verified data tables)
- Scale without maintaining servers
A simple HTML-to-PDF library wasn't going to cut it. We needed pixel-accurate rendering with full CSS support, which meant a headless browser.
Architecture
The system has four moving parts:
Frontend → Backend API → SQS Queue → Lambda (PDF Service) → S3The flow works like this:
- Frontend requests a PDF via the backend API
- Backend gathers the template data, generates a pre-signed S3 upload and download URL, and drops a message onto an SQS queue
- The Lambda-based PDF service picks up the message, renders the template in a headless browser, generates the PDF, and uploads it to S3
- Frontend polls the pre-signed download URL every 5 seconds
- Once available, the file downloads automatically. If it's not ready within 60 seconds, the user sees an error
Decoupling generation from the request via SQS meant the API responded instantly and the heavy lifting happened asynchronously.
Templating with Handlebars
We used Handlebars templates to define the PDF structure. Each client could have their own template overrides, but most shared a base template with configurable sections.
<div class="page">
<header class="header">
<img src="{{clientLogo}}" alt="{{clientName}}" />
</header>
<main>
{{#if showRequestDetails}}
<section>
<h2>Request Details</h2>
<dl>
<dt>Submitted</dt>
<dd>{{formatDate submittedDate}}</dd>
<dt>Completed</dt>
<dd>{{formatDate completedDate}}</dd>
</dl>
</section>
{{/if}}
{{#if showCandidateInfo}}
<section>
<h2>Candidate Information</h2>
{{#each candidateFields}}
<dl>
<dt>{{this.label}}</dt>
<dd>{{this.value}}</dd>
</dl>
{{/each}}
</section>
{{/if}}
</main>
<footer class="footer">
<span>{{clientName}} — Confidential</span>
<span>Page {{pageNumber}}</span>
</footer>
</div>Sections like Request Details, Candidate Information, Activities Timeline, and Verified Candidate Data could be toggled on or off per client via configuration flags. This kept the template flexible without needing per-client forks.
Headless browser on Lambda
The key technical challenge was running a headless browser inside Lambda. We used Puppeteer with chrome-aws-lambda (a Chromium binary optimised for Lambda's execution environment).
import chromium from "chrome-aws-lambda";
import puppeteer from "puppeteer-core";
async function generatePdf(html: string): Promise<Buffer> {
const browser = await puppeteer.launch({
args: chromium.args,
executablePath: await chromium.executablePath,
headless: chromium.headless,
});
const page = await browser.newPage();
await page.setContent(html, { waitUntil: "networkidle0" });
const pdf = await page.pdf({
format: "A4",
printBackground: true,
displayHeaderFooter: true,
margin: { top: "80px", bottom: "60px", left: "40px", right: "40px" },
});
await browser.close();
return Buffer.from(pdf);
}This gave us full CSS support — client fonts, colours, complex table layouts, and proper page breaks all rendered correctly.
Client branding
Each client had a branding configuration stored in the database:
- Primary and secondary colours
- Logo URL
- Font family
- Which sections to show/hide
The backend injected these values into the Handlebars template context before sending the message to the queue. CSS custom properties made it straightforward to apply colours and fonts without template duplication.
:root {
--brand-primary: {{brandPrimaryColor}};
--brand-font: {{brandFontFamily}};
}
.header { background-color: var(--brand-primary); }
body { font-family: var(--brand-font), sans-serif; }File naming and delivery
The generated PDF file name followed a strict format for compliance:
CandidateFullName_dd.mm.yyyy.pdfAfter generation, the Lambda uploaded the PDF to S3 using the pre-signed URL. The frontend polled the download URL on a 5-second interval. We added a 60-second timeout — if the PDF wasn't available by then, the user saw a clear error message rather than spinning indefinitely.
What we learned
Cold starts matter. Lambda cold starts with a headless browser were significant — sometimes 8-10 seconds. We used provisioned concurrency for the production environment to keep a warm instance ready.
Memory allocation affects CPU. Lambda allocates CPU proportionally to memory. We found that bumping memory from 1024MB to 2048MB cut generation time nearly in half, because Chromium got more CPU to work with.
Page breaks need explicit handling. CSS page-break-before and page-break-inside: avoid were essential for preventing content from being cut mid-section. We had to test with real data volumes, not just sample data.
Headers and footers are tricky. Puppeteer's built-in displayHeaderFooter option is limited. We ended up rendering headers and footers as part of the HTML template with fixed positioning and using CSS @page margins to reserve space for them.
Results
- PDFs generated in under 15 seconds on average (with provisioned concurrency)
- Supported 20+ client-specific brand configurations
- Zero partial or broken PDFs in production
- Engineers could add new sections or modify layouts without touching the Lambda code — just update the Handlebars template
The decoupled architecture meant the PDF service could scale independently, and the polling approach kept the frontend simple without needing WebSocket infrastructure.
Sometimes the boring architecture is the right one.