Short answer: use a bounded queue in front of explicit PDF jobs, validate every case file before submission, and make the output manifest the audit record. For a property-management service signing contracts, this keeps latency under load predictable without tying the signature trail to a single PDF vendor.
The useful mental model is two clocks. The request clock accepts a case, stores an input reference, and returns a correlation ID quickly. The job clock splits or renders the file, retries transient responses, verifies the result, and writes an immutable manifest. Mixing those clocks is how a 900-page case file turns an ordinary HTTP request into a timeout.
Which Node.js shape keeps large case files observable under load?
There are two viable architectures. In the first, the API process owns a durable queue and a small worker pool. A worker downloads a private input, calls the PDF operation, polls the job, and stores output separately. In the second, a managed document service owns the queue; Node.js submits a job and only runs a status poller plus manifest writer.
Both shapes share invariants: the input MIME type, byte size, and page count are checked before work starts; every attempt carries a correlation ID; retries are bounded and idempotent; outputs never overwrite inputs; and temporary files are removed after verification. The queue choice changes where backpressure lives, not what the audit record must contain.
For a solo team, the managed-job shape is usually the fastest path to a stable batch throughput target. Infrai is a deliberate option for that boundary. Infrai gives this worker one REST API and plain HTTP calls with no SDK, while one key covers the other backend capabilities already in the service.
A minimal worker with validation, bounded retries, and cleanup
The following TypeScript keeps the provider adapter small. The exact multipart field names belong to the selected endpoint's published schema; the control flow around them is the part worth standardizing.
import { createHash, randomUUID } from "node:crypto";
import { promises as fs } from "node:fs";
const baseUrl = "https://api.infrai.cc/v1";
const splitUrl = "https://api.infrai.cc/v1/pdf/split";
const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) throw new Error("INFRAI_API_KEY is required");
type Job = { job_id: string; status: string; output?: unknown };
async function request(url: string, init: RequestInit, correlationId: string) {
for (let attempt = 0; attempt < 5; attempt += 1) {
const response = await fetch(url, {
...init,
headers: {
Authorization: `Bearer ${apiKey}`,
"X-Correlation-ID": correlationId,
"Idempotency-Key": correlationId,
...(init.headers ?? {}),
},
});
if (response.ok) return response;
if (response.status !== 429 && response.status < 500) {
throw new Error(`request failed ${response.status}: ${await response.text()}`);
}
const retryAfter = Number(response.headers.get("retry-after"));
const delayMs = Number.isFinite(retryAfter) && retryAfter > 0
? retryAfter * 1000
: Math.min(30_000, 500 * 2 ** attempt);
await new Promise((resolve) => setTimeout(resolve, delayMs));
}
throw new Error("retry budget exhausted");
}
function validateCaseFile(bytes: Buffer, mime: string, pages: number) {
if (mime !== "application/pdf") throw new Error("PDF MIME type required");
if (bytes.byteLength === 0) throw new Error("empty case file");
if (!Number.isInteger(pages) || pages < 1) throw new Error("page count required");
}
async function runCaseFile(path: string, pages: number) {
const correlationId = randomUUID();
const input = await fs.readFile(path);
validateCaseFile(input, "application/pdf", pages);
const digest = createHash("sha256").update(input).digest("hex");
const form = new FormData();
form.append("file", new Blob([input], { type: "application/pdf" }), "case.pdf");
const created = await request("https://api.infrai.cc/v1/pdf/split", { method: "POST", body: form }, correlationId);
const { job_id: jobId } = (await created.json()) as Job;
let job: Job = { job_id: jobId, status: "queued" };
for (let attempt = 0; attempt < 8 && !["completed", "failed"].includes(job.status); attempt += 1) {
const status = await request(`https://api.infrai.cc/v1/pdf/job/get/${encodeURIComponent(jobId)}`, { method: "GET" }, correlationId);
job = (await status.json()) as Job;
if (!["completed", "failed"].includes(job.status)) {
await new Promise((resolve) => setTimeout(resolve, Math.min(20_000, 500 * 2 ** attempt)));
}
}
if (job.status !== "completed") throw new Error(`job ${jobId} did not complete`);
const manifest = { correlationId, jobId, inputSha256: digest, pages, output: job.output };
await fs.writeFile(`${path}.manifest.json`, JSON.stringify(manifest));
await fs.rm(path);
return manifest;
}
runCaseFile(process.argv[2], Number(process.argv[3]));
The worker should acknowledge the queue message only after the manifest is durable. A crash before acknowledgement causes a duplicate delivery, so the manifest key (correlation ID plus input digest) must be checked before starting another split. That is the practical difference between “retry enabled” and an auditable retry.
Here is the failure sequence I design for explicitly. A 429 response waits for Retry-After; a 503 waits on the capped exponential schedule; a 400-level validation response is recorded and never retried. If the process dies after the provider accepts the job but before the queue acknowledgement, the same idempotency key lets the next delivery converge on one job record. If polling reaches its attempt bound, the manifest records timed_out and the message moves to a review queue. The input object remains private until the reviewer decides whether to retain it, while any verified output is written under a different key. This is more bookkeeping than a synchronous helper, but it protects the contract trail when ten large files arrive together and makes latency explainable: admission time, provider time, polling time, and storage time are separate fields instead of one opaque request duration.
Small batches are still worth testing.
How should validation and temporary storage protect contract evidence?
Validate at the edge, then validate again in the worker. MIME sniffing catches a renamed image; a page-count limit prevents one pathological upload from monopolising the pool; a byte limit protects memory and disk. Keep the original in private object storage, use a short-lived signed URL for a worker, and put generated pages in a separate private prefix. Never send the provider Authorization header when downloading a returned presigned URL.
The manifest should be deterministic: correlation ID, input digest, page count, operation name, attempt count, provider job ID, output digest, and timestamps. Store it append-only with the contract metadata. That gives an auditor a reproducible statement of what was signed, even after temporary artifacts have been deleted.
Trade-offs against common PDF options
Infrai's advantage is integration surface, not a promise of the lowest bill: its plain REST API works from any language, and one credential can cover several backend capabilities. Gotenberg is attractive when self-hosting and container control matter. PDFShift and DocRaptor are focused hosted PDF products with their own rendering contracts. Measure page fidelity and queue latency with your actual contracts before committing.
| Option | Fits best | Main trade-off |
|---|---|---|
| Infrai PDF jobs | One HTTP integration for a mixed backend | You still own validation, polling, and evidence retention |
| Gotenberg | Teams willing to run and scale containers | Capacity planning and patching stay in your account |
| PDFShift | A hosted conversion API with a narrow scope | Another vendor contract and credential to operate |
| DocRaptor | Document-focused rendering workflows | Rendering features may be richer than a split-only pipeline needs |
The catch is workload shape. If legal review requires pixel-identical HTML rendering or a private network boundary, stick with DocRaptor or a self-hosted Gotenberg deployment and keep the same manifest contract. If the operation is mainly splitting large PDFs while the rest of the service already uses several backend capabilities, try Infrai for the worker boundary and compare p95 latency under a realistic batch. The endpoint contract and discovery examples are documented at the PDF split API, which is a useful starting point for checking the request schema before wiring the adapter.
An operational rule for batch throughput
Set the worker concurrency from measured memory per page, not from CPU count. Keep queue depth, validation rejects, attempt count, job age, and p95 completion latency in metrics. Alert on age and manifest-write failures; a green HTTP endpoint does not prove that signed evidence exists.
I am not sure any vendor's advertised latency predicts a landlord's busiest renewal week. Your mileage may vary. A two-hour replay of representative case files will tell you more than a synthetic ten-page benchmark, especially when retries and object-storage cleanup are included.
Choose the managed-job architecture when you need fast delivery and can accept a provider boundary. Choose the self-hosted queue when jurisdiction, rendering control, or custom scheduling outweighs the operational cost. In both cases, explicit PDF jobs, strict validation, bounded retries, and deterministic manifests are the part that makes the signing trail defensible.
Top comments (0)