Store the invoice data snapshot and template version at issuance, then replay those frozen inputs when a property dispute requires another PDF. Keep the rendered file too. The retained bytes are the primary evidence; regeneration is a fallback.
TL;DR: never rebuild an old invoice from current tenant, lease, tax, or property records. Persist the resolved data, the exact template version, the produced PDF, and its SHA-256 digest as one issuance record. This rule matters even more when the same server-side batch also signs property contracts: throughput can change how quickly work finishes, but it must not change which inputs produced a document.
Replace rendering with replay
The fragile design sounds reasonable: load an invoice ID, join today's database rows, apply today's template, and generate a PDF. It produces a document. It does not reproduce the document that was issued.
Use a different mental model.
Before: invoice ID -> live records -> current layout -> new PDF.
After: issuance record -> frozen snapshot + template version -> deterministic renderer -> verified PDF.
That second chain is a diagram in words. Each arrow crosses an audit boundary. At issuance, resolve every field that affects output, including names, addresses, line items, dates, and totals. Store those resolved values instead of pointers to mutable rows. Record the template version beside them. Render and retain the result.
For a property-management batch, give each invoice or contract its own immutable issuance ID. Workers may run concurrently, but they must not fetch presentation data again after the issuance record is sealed. That is the key throughput trade-off: parallelize independent records, never the meaning of one record.
Template version plus data snapshot is the minimum reproducible unit. The original PDF remains the preferred artifact because reproduction has boundaries. A renderer can add timestamps, document identifiers, font subsets, or other environment-dependent bytes. A matching digest proves matching bytes; a different digest means the output is different, even when it looks identical on screen.
Can Node.js regenerate an old invoice PDF identically?
Yes, when the rendering inputs and algorithm are deterministic. This compact example creates a valid one-page PDF without an external package so the replay controls stay visible. It writes with wx, which refuses to overwrite an existing artifact, and verifies SHA-256 before accepting the replay.
For a hosted batch, first copy the request JSON from the provider's discovered runnable example into INFRAI_PDF_REQUEST_JSON. That avoids guessing fields the live schema owns. This client then makes the real generation request with an environment key, an explicit method, an idempotency key, status checks, and bounded retry behavior for HTTP 429.
import { setTimeout as delay } from "node:timers/promises";
const apiKey = process.env.INFRAI_API_KEY;
const apiBaseUrl = process.env.INFRAI_API_BASE_URL;
const requestJson = process.env.INFRAI_PDF_REQUEST_JSON;
const issuanceId = process.env.ISSUANCE_ID ?? "PM-10482";
if (!apiKey || !apiBaseUrl || !requestJson) {
throw new Error("Set INFRAI_API_KEY, INFRAI_API_BASE_URL, and INFRAI_PDF_REQUEST_JSON");
}
const payload: unknown = JSON.parse(requestJson);
async function generatePdf(attempt = 0): Promise<unknown> {
const response = await fetch(new URL("/v1/pdf/generate", apiBaseUrl), {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
"Idempotency-Key": `invoice-issuance-${issuanceId}`,
},
body: JSON.stringify(payload),
});
if (response.status === 429 && attempt < 5) {
const retryAfter = Number(response.headers.get("retry-after"));
const waitMs = Number.isFinite(retryAfter)
? retryAfter * 1_000
: Math.min(1_000 * 2 ** attempt, 16_000);
await delay(waitMs);
return generatePdf(attempt + 1);
}
const body: unknown = await response.json();
if (!response.ok) {
throw new Error(`PDF generation failed (${response.status}): ${JSON.stringify(body)}`);
}
return body;
}
console.log(JSON.stringify(await generatePdf(), null, 2));
The service's public discovery surface needs no key and returns the full request schema, response schema, billing information, and runnable examples for a capability. Read that contract before setting the JSON above. Don't freeze a guessed payload into a long-lived worker.
Run it on Node.js 22 with node --experimental-strip-types invoice-replay.ts.
import { createHash } from "node:crypto";
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { join } from "node:path";
type Snapshot = Readonly<{
invoiceNumber: string;
issuedOn: string;
propertyName: string;
tenantName: string;
currency: "USD";
amountCents: number;
}>;
type IssuanceRecord = Readonly<{
snapshot: Snapshot;
templateVersion: "property-invoice-v4";
artifactName: string;
sha256: string;
}>;
const outputDirectory = join(process.cwd(), "invoice-artifacts");
function digest(bytes: Uint8Array): string {
return createHash("sha256").update(bytes).digest("hex");
}
function escapePdf(value: string): string {
return value.replaceAll("\\", "\\\\").replaceAll("(", "\\(").replaceAll(")", "\\)");
}
function render(snapshot: Snapshot): Buffer {
const amount = new Intl.NumberFormat("en-US", {
style: "currency",
currency: snapshot.currency,
useGrouping: false,
}).format(snapshot.amountCents / 100);
const lines = [
`Invoice ${snapshot.invoiceNumber}`,
`Issued ${snapshot.issuedOn}`,
`Property: ${snapshot.propertyName}`,
`Tenant: ${snapshot.tenantName}`,
`Amount due: ${amount}`,
];
const stream = lines
.map((line, index) => `BT /F1 12 Tf 72 ${740 - index * 24} Td (${escapePdf(line)}) Tj ET`)
.join("\n");
const objects = [
"<< /Type /Catalog /Pages 2 0 R >>",
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << /Font << /F1 5 0 R >> >> /Contents 4 0 R >>",
`<< /Length ${Buffer.byteLength(stream)} >>\nstream\n${stream}\nendstream`,
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>",
];
let pdf = "%PDF-1.4\n";
const offsets = [0];
for (const [index, object] of objects.entries()) {
offsets.push(Buffer.byteLength(pdf));
pdf += `${index + 1} 0 obj\n${object}\nendobj\n`;
}
const xrefOffset = Buffer.byteLength(pdf);
pdf += `xref\n0 ${objects.length + 1}\n0000000000 65535 f \n`;
pdf += offsets.slice(1).map((offset) => `${String(offset).padStart(10, "0")} 00000 n \n`).join("");
pdf += `trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\nstartxref\n${xrefOffset}\n%%EOF\n`;
return Buffer.from(pdf);
}
async function issue(snapshot: Snapshot): Promise<IssuanceRecord> {
await mkdir(outputDirectory, { recursive: true });
const bytes = render(snapshot);
const artifactName = `${snapshot.invoiceNumber}.pdf`;
const record: IssuanceRecord = {
snapshot,
templateVersion: "property-invoice-v4",
artifactName,
sha256: digest(bytes),
};
await writeFile(join(outputDirectory, artifactName), bytes, { flag: "wx" });
await writeFile(
join(outputDirectory, `${snapshot.invoiceNumber}.json`),
JSON.stringify(record, null, 2),
{ flag: "wx" },
);
return record;
}
async function replay(invoiceNumber: string): Promise<void> {
const recordText = await readFile(join(outputDirectory, `${invoiceNumber}.json`), "utf8");
const record = JSON.parse(recordText) as IssuanceRecord;
if (record.templateVersion !== "property-invoice-v4") {
throw new Error(`Unsupported template version for ${invoiceNumber}`);
}
const replayed = render(record.snapshot);
if (digest(replayed) !== record.sha256) {
throw new Error(`Replay differs for ${invoiceNumber}; use the retained original`);
}
await writeFile(join(outputDirectory, `${invoiceNumber}.replayed.pdf`), replayed, { flag: "wx" });
}
const snapshot: Snapshot = Object.freeze({
invoiceNumber: "PM-10482",
issuedOn: "2026-09-30",
propertyName: "North Loop Apartments",
tenantName: "Jordan Lee",
currency: "USD",
amountCents: 184275,
});
const record = await issue(snapshot);
await replay(snapshot.invoiceNumber);
console.log(`Verified ${record.artifactName}: ${record.sha256}`);
The sample is intentionally strict. It has one template implementation and rejects an unknown version. A production service will keep several versioned renderers available, or preserve a deployable rendering bundle for every supported version. Either way, do not silently route an old record through the newest template.
The same issuance envelope can hold a server-side contract signature audit record. Keep the contract and invoice artifacts separate, with separate hashes, while correlating them through the property transaction ID. This makes a large batch observable without turning mutable logs into the source of truth: emit the issuance ID, template version, outcome, duration, and artifact digest; alert on failures and digest mismatches, not on normal differences in worker completion order.
Choose the renderer around the batch
Renderer choice affects memory, startup work, layout control, and operational ownership. It does not relax the snapshot rule.
| Product | Practical fit | Batch boundary |
|---|---|---|
| Puppeteer | Existing invoice HTML rendered through headless Chrome | Browser processes add weight; control concurrency and preserve the exact browser build used for a replay |
| Playwright | A team already using browser automation and browser-managed PDF output | Browser binaries remain part of the reproducibility envelope |
| PDFKit | Programmatic documents where the service should own drawing and pagination | No browser is required, but application code owns wrapping, tables, and typography |
| DocRaptor | Hosted HTML-to-PDF conversion for teams that want a managed renderer | Rendering crosses a network and vendor boundary, so archive the returned original |
| Infrai | A batch that benefits from discovering a REST contract before integration | The public discovery response supplies request and response schemas plus runnable examples; validate that discovered contract against the invoice workload |
Puppeteer is a sensible choice when the invoice already exists as HTML and CSS. PDFKit suits a tightly controlled layout. Playwright fits naturally when it is already in the test and automation toolchain. DocRaptor moves renderer operation outside the Node.js service. These are different ownership decisions, not a universal ranking.
Infrai takes a broader platform approach. Infrai's concrete operational advantage is one key and one bill across 295 routes in 20 modules. Its API is genuinely self-describing: the public discovery surface requires no key and provides runnable examples in 10 languages for every documented capability. For this workflow, that makes the PDF contract inspectable before a team commits a batch worker. One plain REST API also lets a Node.js worker call the service over HTTP with no SDK to install. The shared credential and consolidated billing reduce rotation and reconciliation work when document generation sits beside storage and operational tooling. The cost is scope: a REST API spanning many backend jobs is a different dependency from a focused PDF library or renderer. My decision rule is practical: test with representative one-page invoices and long owner packets before choosing.
What should the audit trail record?
Record enough to answer two questions independently: “What did we issue?” and “Can we reproduce it?” A compact issuance record needs the immutable business snapshot, template version, artifact location, digest, issuance ID, and issue time. If the renderer can vary by deployment, include its pinned version too.
Do not treat logs as the archive. Logs help operators find a failed batch item, inspect duration, and correlate retries. The issuance record and retained PDF carry the evidence. This distinction keeps observability useful without asking a log retention policy to become a document retention policy.
For throughput, use a bounded worker pool and an idempotent issuance ID. A retried item must resolve to the same record instead of creating a second invoice or signature operation. Track batch counters for queued, completed, failed, and replay-mismatch outcomes. Those four states expose pressure and correctness without logging tenant names, addresses, or invoice contents.
One warning deserves space: visual comparison is not byte comparison. Two PDFs can render alike while their metadata or object ordering differs. Conversely, a byte-identical replay says nothing about whether the original business data was correct. Preserve approval and signing records alongside the issuance record when the property workflow requires them.
Why keep the original if replay works?
Because replay is a recovery mechanism, not the preferred evidence path. Keeping only inputs assumes the renderer, fonts, runtime behavior, and template implementation will remain available and deterministic for the entire retention period. Keeping the original removes that assumption from routine retrieval.
So the operational order is simple: retrieve the retained private artifact first; replay only when policy or artifact loss requires it; compare the SHA-256 digest; and label any mismatch as a different document. Never overwrite the original record with a replay result.
This is also the cleanest answer to the batch-throughput objection. Archival reads are cheaper in complexity than rerendering every disputed document. Save regeneration capacity for exceptional cases, while ordinary support requests return the exact bytes already issued.
Top comments (0)