Certificate Generation at Scale: Use Cases and Implementation
Certificates look simple — a name, a date, maybe a seal — but generating them at volume reliably is surprisingly involved. Once you move past one-off PDFs, you run into font rendering issues, Unicode names that break layouts, throughput limits, and the need to verify documents months or years after they were issued. This post walks through the common use cases, the architecture that holds up under load, and concrete implementation patterns.
Common use cases
The patterns repeat across industries:
- Course completion certificates — issued at the end of an online course, often thousands per batch when a cohort finishes.
- Compliance and training records — HR and regulated industries (finance, healthcare) need auditable proof of completed training.
- Event attendance — conferences issuing certificates of attendance, sometimes generated on-demand from a check-in scan.
- Professional credentials — accreditation bodies issuing signed, verifiable certificates.
- Awards and recognition — internal recognition platforms, gamified employee programs.
What unites them: a fixed visual template, dynamic per-recipient data, and the need to scale from one to one million without rewriting the pipeline.
The naive approach (and why it breaks)
The first version most teams ship looks like this: an HTML template, Puppeteer running locally, a loop that renders one PDF at a time.
tsimport puppeteer from "puppeteer"; for (const recipient of recipients) { const browser = await puppeteer.launch(); const page = await browser.newPage(); await page.setContent(renderTemplate(recipient)); await page.pdf({ path: `${recipient.id}.pdf` }); await browser.close(); }
This works for 10 recipients. At 10,000, you'll hit:
- Chromium memory leaks crashing your worker
- Cold-start latency on every render
- No retry logic when a render fails
- Fonts that render differently on different OS images
- Sync work blocking your API server
The fix isn't a bigger machine — it's separating the rendering work from your application.
A scalable architecture
A reliable certificate pipeline has four pieces:
- A template — versioned, deterministic, with locked fonts.
- A renderer — a dedicated service (yours or managed) that turns template + data into a PDF.
- A job queue — so issuance is asynchronous and retryable.
- Storage with a signed URL — so recipients can download without you proxying bytes.
Here's the shape of an issuance handler using a queue like BullMQ:
tsimport { Queue } from "bullmq"; const certQueue = new Queue("certificates"); export async function issueCertificate(userId: string, courseId: string) { await certQueue.add("render", { userId, courseId, issuedAt: new Date().toISOString(), }); }
The worker consumes the job, calls the renderer, and stores the result:
tsimport { Worker } from "bullmq"; import { kamy } from "@kamydev/sdk"; new Worker("certificates", async (job) => { const { userId, courseId, issuedAt } = job.data; const user = await db.user.findUnique({ where: { id: userId } }); const course = await db.course.findUnique({ where: { id: courseId } }); const pdf = await kamy.documents.render({ template: "course-certificate-v3", data: { recipientName: user.fullName, courseTitle: course.title, issuedAt, certificateId: `CERT-${userId}-${courseId}`, }, }); await db.certificate.create({ data: { userId, courseId, url: pdf.url, issuedAt }, }); });
Using a managed renderer like Kamy means you don't run Chromium yourself. You upload an HTML/CSS template once, then send JSON to render it. Fonts are bundled with the template, so output is byte-identical across runs.
Batching for large cohorts
When a course cohort of 5,000 students finishes simultaneously, queuing 5,000 individual jobs works but isn't optimal. Most renderers support batch endpoints:
tsconst results = await kamy.documents.renderBatch({ template: "course-certificate-v3", items: recipients.map((r) => ({ data: { recipientName: r.name, courseTitle: r.courseTitle, issuedAt: r.issuedAt, certificateId: r.certId, }, })), });
A batch call amortizes auth, template parsing, and font loading. In practice you'll see 3–5x throughput versus single calls.
Designing the template
A few rules that save pain later:
- Use CSS
@pagefor size — A4 or US Letter, landscape for most certificates. - Constrain dynamic text — use
max-widthandtext-overflowor scale font size for long names. - Lock fonts — embed WOFF files; don't rely on system fonts.
- Test with edge cases — names with combining diacritics, RTL scripts, very long titles.
A minimal template snippet:
html<style> @page { size: A4 landscape; margin: 0; } body { font-family: "Inter", sans-serif; } .name { font-size: 48px; max-width: 80%; overflow-wrap: break-word; } </style> <div class="certificate"> <h1>Certificate of Completion</h1> <p class="name">{{recipientName}}</p> <p>has completed {{courseTitle}}</p> <p>Issued {{issuedAt}} — ID {{certificateId}}</p> </div>
Verification
Certificates are often presented years later. Build verification in from