Certificate Generation at Scale: Use Cases and Implementation
Certificates look simple from the outside: a name, a date, a signature, maybe a QR code. But when you need to issue 50,000 of them after a webinar, or generate a unique completion certificate every time a user finishes a course, the engineering problem changes shape. You're no longer designing a document — you're designing a pipeline.
This post walks through the common use cases for programmatic certificate generation, the architectural decisions that matter at scale, and how to actually wire it up.
Where certificate generation shows up
A few real-world scenarios where teams hit this problem:
- Online learning platforms issuing course completion certificates per user, per course.
- Conference and webinar tools generating attendance certificates after an event ends.
- Compliance and training systems that need auditable proof of completion (HIPAA, GDPR training, safety certifications).
- Professional bodies issuing membership cards, CPD credits, or accreditation documents.
- Marketplaces and platforms generating verified badges or partner certificates.
The common thread: each document is mostly the same, but personalized, and they need to be produced reliably in batches or on demand.
What "at scale" actually means
Before writing code, define your load profile. Three numbers matter:
- Peak concurrency — how many certificates need to be generated within the same minute?
- Total volume — daily and monthly issuance.
- Latency requirements — does the user wait for the PDF, or is it emailed later?
A platform generating 200 certificates synchronously after a class ends is a very different system from one batching 100,000 overnight. The first needs predictable per-request latency. The second needs throughput and retry logic.
Architecture: synchronous vs. queue-based
For on-demand generation (user finishes a course, clicks "Download Certificate"), keep it synchronous and aim for sub-second response times. Generate, return a signed URL, done.
For batch generation (post-event, scheduled cohorts), use a queue. The flow looks like this:
txtEvent trigger → Job enqueued (per recipient) → Worker pulls job → Render PDF → Upload to storage → Email or notify user → Mark job complete
This pattern gives you retries, backpressure, and observability for free if you use something like BullMQ, SQS, or Cloud Tasks.
Designing the template
Avoid hand-rolling PDFs with low-level libraries like PDFKit unless you really need to. HTML + CSS templates are easier to iterate on, easier for designers to touch, and easier to preview in a browser.
A typical certificate template needs:
- A background design (often a pre-made image or SVG).
- Dynamic fields: recipient name, course title, completion date, certificate ID.
- A verification QR code or short URL.
- Optional: signature image, organization seal.
Here's what a Handlebars-style template might look like:
html<!DOCTYPE html> <html> <head> <style> @page { size: A4 landscape; margin: 0; } body { font-family: 'Cormorant Garamond', serif; } .cert { background: url('/bg.png') no-repeat; width: 297mm; height: 210mm; } .name { font-size: 64pt; text-align: center; margin-top: 320px; } .meta { text-align: center; font-size: 16pt; margin-top: 20px; } </style> </head> <body> <div class="cert"> <h1 class="name">{{recipient.fullName}}</h1> <p class="meta">has successfully completed</p> <h2 class="meta">{{course.title}}</h2> <p class="meta">on {{completedAt}} · ID: {{certificateId}}</p> <img src="{{qrCodeUrl}}" class="qr" /> </div> </body> </html>
Implementation with Kamy
Running headless Chrome at scale is solvable but operationally annoying — memory leaks, font installation, concurrency tuning, browser pool management. If you'd rather not run that infrastructure, Kamy handles the rendering layer.
Register the template once, then render per recipient:
tsimport { kamy } from "@kamydev/sdk"; async function issueCertificate(user, course) { const pdf = await kamy.documents.render({ template: "course-certificate", data: { recipient: { fullName: user.fullName }, course: { title: course.title }, completedAt: new Date().toLocaleDateString("en-US"), certificateId: user.certificateId, qrCodeUrl: `https://verify.example.com/c/${user.certificateId}`, }, }); return pdf.url; // signed URL, valid for 1 hour }
For batch issuance, wrap that in a worker:
tsimport { Worker } from "bullmq"; new Worker("certificates", async (job) => { const { userId, courseId } = job.data; const user = await db.users.findById(userId); const course = await db.courses.findById(courseId); const url = await issueCertificate(user, course); await db.certificates.update(user.certificateId, { pdfUrl: url }); await sendEmail(user.email, "Your certificate", { url }); }, { concurrency: 20 });
Things that bite you in production
A few practical issues worth flagging:
- Fonts. Custom fonts must be embedded or available to the renderer. Missing fonts silently fall back to defaults and ruin the design.
- Unicode names. Test with names containing accents, CJK characters, RTL scripts. Many setups break on these.
- Idempotency. If a job retries, don't issue two certificates