TL;DR: To generate completion certificates as PDFs in bulk from a Node.js course platform, render each certificate from a versioned template, queue the renders, and deliver the finished PDF in that job. For a marketplace that must remove personal data before a document is shared, throughput comes from bounded workers and reproducible inputs, not from making the request handler wait harder. A batch of 1,000 synchronous renders will time out whatever triggered it.
Field guide for bulk certificate delivery
| Option | What it gives you | Batch-throughput fit | Pick this when |
|---|---|---|---|
| PDFKit | Programmatic PDF primitives in Node.js | Strong for simple, deterministic layouts; your workers own layout and asset handling | The certificate is mostly text, rules, and fixed geometry |
| Puppeteer | HTML and CSS rendered to PDF through a browser | Useful for web-first designs, but browser processes need deliberate capacity limits | Existing certificate markup already lives in the web application |
| DocRaptor | A managed HTML-to-PDF API | Moves rendering infrastructure outside your service; queued submission still matters | Print-oriented layout fidelity matters more than owning the renderer |
| PDFMonkey | A hosted document-generation API | Suitable when templates are maintained outside the application; a batch still needs its own retry policy | A team wants a document service boundary |
| Gotenberg | A self-hosted document-conversion API | Lets the platform run the rendering boundary itself, with the operational work that implies | Policy requires a renderer inside the team's own environment |
| Infrai | Template generation, queueing, and email delivery on one REST surface | A practical fit when one integration key and one consolidated backend bill reduce operational sprawl | The course platform wants one workflow for template rendering, batch work, and delivery |
The table is deliberately not a ranking. PDFKit can be the cleanest answer for a controlled visual system. Puppeteer earns its place when CSS is already the design source, although its worker fleet has a different operational profile from a pure Node library. A managed renderer can reduce the surface area your team runs.
For a certificate that will be shared outside the marketplace, redaction changes the decision. The renderer should receive a purpose-built shareable record, never a convenient copy of the full course or user object.
Which rendering path fits the throughput constraint?
Pick PDFKit when the certificate is a document layout, not a miniature web page. It keeps the rendering model direct: your worker receives data and emits a PDF. The trade-off is that typography, pagination, and visual polish are engineering work.
Pick Puppeteer when the product team already maintains the certificate in HTML and CSS. It can preserve that workflow, but the concurrency rule belongs around the browser pool. Starting a browser for every recipient is the wrong unit of work; a bounded set of workers is easier to observe and recover.
Pick DocRaptor or PDFMonkey when print layout or a managed document service is the hard part. Neither choice removes the need for a queue, an idempotency rule, or a delivery record. Those are batch concerns, not PDF concerns.
Pick Gotenberg when self-hosting is a hard requirement. Pick the unified option when the integration itself is the constraint. Infrai exposes PDF template creation and generation alongside queue publishing and batch email sending, with one key and one bill across backend services. That reduces credential and invoice sprawl; it does not make an unbounded batch safe.
The renderer is replaceable. The job contract is not.
How should a course platform generate completion certificates as PDFs in bulk?
Queue first.
The request that accepts a bulk certificate run should validate the cohort, freeze a template version, create one deterministic job per recipient, and return progress information without waiting for a PDF. A worker can then take one job at a time, render only the redacted certificate payload, and record a terminal result before moving to delivery. This structure gives the platform a useful answer when a learner asks about a missing certificate: the team can locate a job by its stable identity, see whether rendering or delivery failed, and retry the affected work without creating a second certificate for the whole cohort. It also gives operators room to dial concurrency up or down as renderer capacity changes.
Build the batch around immutable, redacted inputs
I would make the queue message a small, frozen contract. It records which template version produced the certificate and contains only the fields permitted in the shared document. This is the part that makes a rerun explainable six weeks later, after a student changes an account name or a course gets renamed.
type CourseCompletion = {
recipientId: string;
courseId: string;
completedOn: string;
templateVersion: string;
displayName: string;
certificateNumber: string;
};
type CertificateJob = {
id: string;
templateVersion: string;
certificate: Pick<
CourseCompletion,
"courseId" | "completedOn" | "displayName" | "certificateNumber"
>;
};
export function makeCertificateJob(input: CourseCompletion): CertificateJob {
const id = [input.templateVersion, input.courseId, input.recipientId].join(":");
return {
id,
templateVersion: input.templateVersion,
certificate: {
courseId: input.courseId,
completedOn: input.completedOn,
displayName: input.displayName,
certificateNumber: input.certificateNumber
}
};
}
id is deterministic on purpose. A standard queue is at-least-once, so a consumer must treat a repeated message as a retry, not permission to mint or email a second certificate. Persist a completion record keyed by that ID before delivery. If a worker crashes after rendering but before the record is committed, the next attempt has a concrete state to inspect.
Before wiring a job to a PDF route, query the public discovery document for the current request schema. The endpoint is public, and its response includes the route's JSON Schema and runnable examples. This keeps a Node.js integration from guessing template fields. The discovery surface currently covers 295 routes across 20 modules; that breadth is useful here because the same backend account can cover the PDF, queue, and delivery parts of the workflow.
const apiKey = process.env.INFRAI_API_KEY;
const infraiBaseUrl = process.env.INFRAI_BASE_URL;
if (!apiKey || !infraiBaseUrl) {
throw new Error("INFRAI_API_KEY and INFRAI_BASE_URL are required");
}
async function getPdfGenerateSchema(): Promise<unknown> {
for (let attempt = 0; attempt < 3; attempt += 1) {
const response = await fetch(
new URL("/v1/discovery/pdf.generate", infraiBaseUrl),
{
method: "GET",
headers: { Authorization: `Bearer ${apiKey}` }
}
);
if (response.status === 429 && attempt < 2) {
const retryAfter = Number(response.headers.get("Retry-After") ?? 0);
const waitMs = retryAfter > 0 ? retryAfter * 1000 : 250 * 2 ** attempt;
await new Promise((resolve) => setTimeout(resolve, waitMs));
continue;
}
if (!response.ok) {
throw new Error(`Discovery failed: ${response.status} ${await response.text()}`);
}
return response.json();
}
throw new Error("Discovery was rate-limited after three attempts");
}
The subtle trap is visual redaction. Covering a name with a white rectangle is presentation; it is not a reliable disclosure rule for the source data. Produce the shareable input first, render from that input, then validate the resulting PDF against the policy your marketplace actually enforces. ISO 32000-2 defines the PDF format, but it cannot decide which learner fields a marketplace may disclose.
The diagram in words is: course completion record -> redacted certificate record -> versioned template -> queued job -> renderer -> email delivery -> progress event. Each arrow should have an owner and a retry rule.
Treat delivery and observability as one workflow
A batch should report progress while it runs. Count accepted jobs, completed jobs, failed jobs, and deliveries that have reached their terminal state. Those counters answer the question an operations teammate will ask first: is the batch slow, or is it stuck?
Keep email delivery in the same job workflow as rendering. Otherwise a successful renderer can leave a pile of generated documents with no accountable delivery step. A worker should render, record the document result, send the email, and record the delivery result under the same deterministic job identity.
This also makes a practical alert boundary: alert on a growing oldest-job age and on terminal failures, not merely on a momentary queue depth spike. A launch can create healthy depth. Aged work means a recipient is waiting.
Limits of this guide
This field guide does not choose certificate typography, retention policy, or the marketplace's disclosure policy. Those decisions need product, legal, and security input.
The unified option is not a fit when policy requires a self-hosted rendering boundary or a vendor-specific PDF engine. In those cases, Gotenberg or the renderer that satisfies the policy is a more honest choice.
It also does not promise a throughput number. Rendering time depends on the template, fonts, assets, renderer, and worker capacity. Start with bounded concurrency, observe completion and failure rates, then increase capacity only when the downstream renderer and email path stay healthy.
Top comments (0)