Certificate Generation at Scale: Use Cases and Implementation
Certificates sound like a solved problem until you need to generate 50,000 of them in an hour. Course completions, training compliance, event attendance, professional licensing — all of these push document generation from a nice-to-have into a core piece of infrastructure. This post walks through the real-world use cases, the architectural choices you'll face, and a working implementation pattern you can adapt.
Common use cases
A few scenarios tend to drive demand for high-volume certificate generation:
- Online learning platforms: Every course completion triggers a personalized certificate. Platforms like Coursera or internal corporate LMS systems may generate tens of thousands per day.
- Compliance and training: Annual security training, HIPAA, OSHA, or similar mandates often require issuing certificates to entire employee populations in a short window.
- Event platforms: Attendance certificates for webinars and conferences, often generated in a burst immediately after an event ends.
- Professional accreditation: CPE/CEU credits where regulators require verifiable, uniquely-numbered certificates.
- Membership and loyalty: Yearly renewals, achievement badges, or tier upgrades that need a printable artifact.
The common thread: templated layout, per-recipient data, and spiky traffic.
The core requirements
Before writing code, pin down these requirements. They heavily influence the architecture:
- Throughput: Peak certificates per minute, not just daily totals.
- Latency: Does the user wait (synchronous download) or do you email it later (async)?
- Uniqueness: Serial numbers, QR codes for verification, tamper-evident signatures.
- Storage and retention: Store the PDF, regenerate on demand, or both?
- Branding flexibility: One template or hundreds for white-labeled customers?
Architecture patterns
For most teams, one of three patterns fits:
Synchronous: Generate inline during an HTTP request. Simple, but it couples your API latency to PDF rendering time. Fine up to a few requests per second.
Queued async: Push a job to a queue (SQS, BullMQ, Sidekiq), render in a worker pool, store the PDF, notify the user. This is the right default for anything beyond trivial scale.
Batched: For known-ahead jobs ("generate certificates for all 12,000 attendees"), fan out a batch into chunks, render in parallel, and track completion. Combine with queued async for the per-job work.
A working implementation
Here's a queue-based pattern using Node.js, BullMQ for job management, and Kamy for the rendering itself. Start with the template. Define it in HTML with placeholders for recipient data:
html<!DOCTYPE html> <html> <body> <div class="certificate"> <h1>Certificate of Completion</h1> <p>This is to certify that</p> <h2>{{recipientName}}</h2> <p>has successfully completed</p> <h3>{{courseName}}</h3> <p>on {{completionDate}}</p> <small>Certificate ID: {{certificateId}}</small> </div> </body> </html>
Upload the template to Kamy once, then reference it by name. The producer side enqueues a job as soon as the user finishes a course:
tsimport { Queue } from "bullmq"; const certQueue = new Queue("certificates", { connection: { host: "localhost", port: 6379 }, }); export async function enqueueCertificate(userId: string, courseId: string) { await certQueue.add("render", { userId, courseId, certificateId: crypto.randomUUID(), }); }
The worker renders the PDF using Kamy and stores the result:
tsimport { Worker } from "bullmq"; import { kamy } from "@kamydev/sdk"; import { s3 } from "./storage"; new Worker( "certificates", async (job) => { const { userId, courseId, certificateId } = 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", data: { recipientName: user.name, courseName: course.title, completionDate: new Date().toLocaleDateString(), certificateId, }, }); await s3.putObject({ Bucket: "certificates", Key: `${certificateId}.pdf`, Body: await fetch(pdf.url).then((r) => r.arrayBuffer()), }); await db.certificate.create({ data: { id: certificateId, userId, courseId }, }); await notifyUser(user.email, certificateId); }, { connection: { host: "localhost", port: 6379 }, concurrency: 20 } );
The concurrency: 20 setting matters. Tune it based on how many parallel renders your PDF service can handle without throttling.
Scaling considerations
A few things that will bite you as volume grows:
Rate limits: Any hosted rendering service (including Kamy) applies per-second limits. Honor them from the worker side with concurrency caps and exponential backoff on 429s.
Batched renders: If you have thousands of certificates to produce at once, use a batch endpoint if available. One request with 500 records is dramatically cheaper than 500 requests:
tsawait kamy.documents.renderBatch({ template: "course-certificate", records: attendees.map((a) => ({ data: { recipientName: a.name, courseName: "Advanced TypeScript", completion