The expensive part of a certificate batch is not the certificate. It's the renderer you picked to draw it. Start a headless browser 40,000 times because a designer wanted a CSS gradient in the header, and you pay 40,000 process startups plus 40,000 font loads; render the template shell once per template version and stamp only the variable fields onto it, and the marginal cost per certificate drops to parsing and writing bytes. That is the fidelity-versus-render-cost trade-off in one sentence, and for a fintech batch — one annual interest certificate per account, merged into one bundle per household, split again when a bundle gets too big to email — it resolves in favour of amortizing the expensive render across the batch instead of repeating it.
| Render strategy | Fidelity ceiling | Marginal cost per certificate | Merge/split behaviour | Pick it when |
|---|---|---|---|---|
| Headless browser, HTML/CSS per document | Highest; anything CSS can express | Highest; process startup, layout and rasterization every time | Needs a second PDF library for merge and split | Template changes weekly, volume is low, layout must reflow |
| Compile-per-document typesetting engine | High typographic control | Medium; a compile per document | Usually a separate merge step | Typography is the product, templates live in version control |
| Pre-rendered shell plus field stamping | Bounded by the shell you rendered once | Lowest; parse, draw, serialize | Native, same library merges and splits | One stable template, tens of thousands of near-identical documents |
For a recurring regulated batch, render the shell once with whatever high-fidelity path you like, cache it by template version, and stamp per-account values onto it with a low-level PDF library that also does merge and split. You get the designer's output and a marginal cost you can actually budget. The catch shows up when content has to reflow, and I'll come back to that.
Where the render cost actually goes
Break the per-document cost into stages before you optimise anything: process or worker startup, font loading and subsetting, layout, rasterization of any image content, compression, then the merge pass that assembles the bundle. Browser-based rendering front-loads almost all of it into startup and layout. A browser pool hides startup, but it trades it for resident memory, and on a 4 GB worker a pool of eight Chromium instances will get you acquainted with the OOM killer well before your queue drains.
Measure in CPU-seconds per thousand documents and peak RSS, not wall-clock on your laptop. Laptop numbers lie because the file cache is warm and the fonts are already resolved.
The merge pass has its own cost that teams routinely forget to count. Each source document carries a resource dictionary — fonts, images, colour spaces — and a merge implementation that simply copies page objects will copy those resources once per source file. Bundle twelve certificates that each embed the same 300 KB font subset and you can end up with twelve copies of it inside one file unless the tool deduplicates by content hash. That's not a defect in any particular library; it's a consequence of how PDF stores indirect objects, described in ISO 32000-2. Check the behaviour of whatever you use: merge two identical documents, look at the output size, and see whether it's roughly 1x or 2x the input. Then decide whether you care. For a bundle emailed to a customer, a 14 MB attachment that should have been 2 MB is a delivery problem, not an aesthetic one.
Splitting is cheaper but not free, because you re-serialize the cross-reference table on every output file.
The fidelity budget is per region, not per document
Treat fidelity as a budget you allocate across regions of the page. The marketing band at the top can drift half a point and nobody will ever notice. The interest figure, the account identifier, the issuer statement and the regulatory disclosure block cannot drift at all, and on a certificate they're the only reason the document exists.
That reframing is what makes the stamping approach defensible. You are not claiming the whole page is pixel-perfect forever. You're claiming that the regions you froze into the shell are identical for every recipient of that template version, and that the regions you stamped are drawn at coordinates you control with a font you embedded yourself.
Embed the fonts. All of them, subsetted. A non-embedded font means the viewer substitutes something metrically different, and the first thing that shifts is digit width, which is exactly where a currency figure lives. If the certificate has to be archived for seven years, the archival PDF/A profiles exist for this reason and they make embedding mandatory rather than advisory.
One more thing that bit me the first time I built one of these: the shell cache key has to include the template version, the font set version and the renderer version. I keyed it on template version alone, shipped a font upgrade, and half the batch rendered against a stale cached shell — same bytes, different intent. Nothing crashed. That's what made it bad.
Stamping, merging and splitting the bundle
The boundary I keep is small on purpose. A shell provider, a stamper, and a bundler. Everything else is application code.
type Account = {
accountId: string;
householdId: string;
holderName: string;
taxYear: number;
grossInterest: string; // formatted upstream, never formatted at draw time
};
type ShellKey = { templateVersion: string; fontSetVersion: string; rendererVersion: string };
export interface ShellProvider {
// Expensive path. Runs once per ShellKey, then it is cache-only.
get(key: ShellKey): Promise<Uint8Array>;
}
export interface Stamper {
stamp(shell: Uint8Array, account: Account): Promise<Uint8Array>;
}
export interface Bundler {
merge(docs: Uint8Array[]): Promise<Uint8Array>;
split(bundle: Uint8Array, maxBytes: number): Promise<Uint8Array[]>;
}
The stamp step writes text into named regions that the shell declares. Resolve region names to coordinates at draw time rather than hard-coding offsets, and fail loudly when a name is missing — a silent miss puts a blank where an account number belongs, and a blank certificate looks perfectly valid to every automated check you have.
Batching is where Node gets opinionated. Stamping is CPU-bound and synchronous inside the PDF library, so a plain for await over 40,000 accounts pins one core and leaves the rest idle. Push the stamp step into worker_threads with a bounded pool sized to the core count, keep the shell in each worker's memory so it is parsed once per worker rather than once per document, and stream each finished bundle to object storage instead of holding it. Backpressure matters more than throughput here: if the producer reads accounts faster than workers can stamp, you buffer tens of thousands of multi-hundred-kilobyte buffers and the process dies at exactly the moment the batch looks like it's working.
async function buildHousehold(
shells: ShellProvider,
stamper: Stamper,
bundler: Bundler,
key: ShellKey,
accounts: Account[],
maxAttachmentBytes: number,
): Promise<Uint8Array[]> {
const shell = await shells.get(key);
const docs: Uint8Array[] = [];
for (const account of accounts) {
docs.push(await stamper.stamp(shell, account));
}
const bundle = await bundler.merge(docs);
return bundle.byteLength <= maxAttachmentBytes
? [bundle]
: bundler.split(bundle, maxAttachmentBytes);
}
Split on a document boundary, never mid-certificate. A household bundle cut in half across page four of a five-page certificate is worse than two emails.
How do you validate a bulk certificate batch before the email goes out?
Validate at four levels, and quarantine rather than drop whatever fails. Structural checks come first: page count matches the expected count for that template version, every font is embedded, and the file opens without a repair pass. Then semantic checks, which are the ones that catch real damage — extract the text layer and assert that the account identifier and the interest figure in the rendered document equal the values in the source record. Extraction is possible because the content stream carries the mapping defined in ISO 32000-2; if your text layer won't extract, your stamper drew glyphs without a usable encoding, and that also means the document is unsearchable and inaccessible to a screen reader.
Then bundle-level checks. This is the one that keeps me up: certificate for account A landing in household B's bundle is a data-protection incident, not a bug report. Assert that the set of account identifiers extracted from the merged bundle is exactly the set you intended to merge, comparing sets rather than counts.
type BundleCheck = { ok: boolean; reasons: string[] };
export function checkBundle(
extractedAccountIds: string[],
expected: Account[],
bundleBytes: number,
maxAttachmentBytes: number,
): BundleCheck {
const reasons: string[] = [];
const got = new Set(extractedAccountIds);
const want = new Set(expected.map((a) => a.accountId));
for (const id of want) if (!got.has(id)) reasons.push(`missing:${id}`);
for (const id of got) if (!want.has(id)) reasons.push(`foreign:${id}`);
if (bundleBytes > maxAttachmentBytes) reasons.push(`oversize:${bundleBytes}`);
return { ok: reasons.length === 0, reasons };
}
Delivery is the fourth level, and it's the one people discover in production. SMTP servers advertise a maximum message size through the SIZE extension in RFC 1870, and a message over that ceiling is rejected with a 552 before anyone reads it. Transient 421 and 452 replies mean slow down, not retry immediately; treat them as backpressure on your send loop and use an idempotency key derived from account set, tax year and template version so a retry can't produce a second certificate. Authenticate the domain with SPF, DKIM and DMARC before the batch, because the large mailbox providers have required authentication, one-click unsubscribe for commercial mail under RFC 8058, and a reported spam rate below 0.3% for bulk senders since 2024. A batch of 40,000 unauthenticated attachments is an excellent way to poison a sending domain in one afternoon.
Sample-render a fixed set of golden accounts per template version and visually diff them against approved images. Not the whole batch — a few dozen, covering the longest name, the longest address, a zero balance and a negative one.
When the cheap path is the wrong path
Stamping onto a fixed shell cannot reflow. If the holder's address is three lines for most customers and nine lines for some, or if a certificate has a variable-length transaction table, the shell has no way to push later content down the page, and you'll end up writing layout code inside a PDF library — the worst possible place to write layout code. That's the point where a per-document render in a browser or a typesetting engine earns its cost, and it's not a close call.
Volume decides the rest. A few hundred certificates a month doesn't justify a shell cache, a worker pool and a golden-image suite; render each one, verify each one, go build something else. The amortization argument only starts paying around the point where render cost shows up as a line item you'd have to explain.
I'm also not sure attachments are the right delivery channel for this content at all, and I'd push back on that requirement before optimising anything downstream of it. A certificate sitting in a mailbox is personal financial data in a place you don't control, forever. An authenticated download link with a short-lived token moves the document behind your access controls, makes revocation possible, and makes the email tiny. Some jurisdictions and some customer agreements genuinely require the document itself to arrive; where they don't, the cheapest bundle to render is the one you never had to split.
Sources
- ISO 32000-2 — Portable Document Format: https://www.iso.org/standard/75839.html
- RFC 5321 — Simple Mail Transfer Protocol: https://datatracker.ietf.org/doc/html/rfc5321
- RFC 1870 — SMTP Service Extension for Message Size Declaration: https://datatracker.ietf.org/doc/html/rfc1870
- RFC 8058 — Signalling One-Click Functional Unsubscribe: https://datatracker.ietf.org/doc/html/rfc8058
- Email sender guidelines (Google Workspace Admin Help): https://support.google.com/a/answer/81126
- Node.js worker_threads documentation: https://nodejs.org/api/worker_threads.html
- Node.js stream backpressure documentation: https://nodejs.org/api/stream.html
Top comments (0)