Short answer: use explicit PDF jobs with strict validation and auditable outputs, then choose the provider that keeps batch latency predictable under load. Fidelity comes first for clauses and signatures; a fast endpoint that silently changes pagination is not fast in a legal workflow.
I run a one-person SaaS, so every infrastructure decision has a revenue-per-hour cost. The goal here is narrow: turn order data into invoice PDFs that accompany contract-review records, in US and EU regions, without making the reviewer wait while a batch drains. Ship weekly. Outsource the undifferentiated parts, but keep the audit trail in our code.
What the PDF job contract must guarantee
Treat generation as a job, even when a small document completes quickly. The request gets a client-generated idempotency key; the result gets a durable record containing input hash, page count, provider request id, and retention deadline. A worker can retry a timed-out call without creating a second invoice. That detail matters more than a pretty SDK.
Validation is a gate, not a log message. Reject missing order identifiers, unsupported currency, and a page estimate beyond the plan before sending bytes to a provider. After completion, inspect the returned file: page count, text extraction for required totals, and a visual sample rendered at 100%. Keep the original and final hashes. Store the PDF privately and hand the browser a short-lived signed link; credentials stay server-side.
Measure twice.
Latency needs a budget. For example, reserve 2 seconds for queueing, 8 seconds for a normal invoice, and a separate timeout for a 500-page review packet. Those are service targets we set, not measurements of any vendor. Record p50, p95, and p99 by page-count bucket, region, and concurrency. One average number hides the queue that hurts Monday morning.
Here is the failure mode I design around: a batch of 40 orders arrives after a sales export, and half the orders contain a scanned exhibit. The first ten jobs look healthy, so a dashboard showing an average of six seconds feels reassuring. Then memory pressure or a provider queue stretches the last ten to two minutes, and the reviewer sees an apparently random gap in the audit log. I would rather mark each job as queued, processing, validated, or rejected, emit the request id with every transition, and let the UI poll a status endpoint than pretend the batch is one atomic request. That extra state costs a few rows in Postgres. It saves a support hour when someone asks why invoice 37 is missing.
How should a Node.js SaaS balance fidelity, latency, and operational complexity?
Start with representative fixtures: scanned pages, embedded fonts, tables that break across pages, redactions, and a signed appendix. Run the same corpus through each candidate at 1, 10, and 50 concurrent jobs. Compare extracted text and rendered pixel diffs, then look at tail latency and the number of moving parts your team must operate.
| Option | Fidelity controls | Load behavior to verify | Operational trade-off |
|---|---|---|---|
| DocRaptor | CSS-to-PDF rendering with a hosted service | Check queue tails and page limits on your corpus | Low platform work; external data path and account limits need review |
| PDFShift | Hosted HTML-to-PDF conversion | Test burst concurrency and timeout behavior | Quick integration; less control over renderer internals |
| Gotenberg | Self-hosted Chromium-based conversion | You own worker capacity and scaling policy | More control and isolation; someone must patch and operate it |
| Infrai PDF API | A single HTTP contract; validate output in your pipeline | Measure queue and processing tails with your fixtures | Less SDK and vendor plumbing; provider boundary and retention policy still need review |
The last row is useful when the team wants one plain REST API: anything that can send HTTP can call it, with no client library version to babysit. It also fits a mixed backend because one key and one consistent interface can cover adjacent capabilities. That is a workflow advantage, not proof of lower latency. Your mileage may vary, and only a load test on your documents can settle the question.
Infrai's concrete advantage here is the plain REST interface: no SDK install, no client-library upgrade cycle, and any language that can send an HTTP request can use the same contract.
Its other useful property is breadth under one key: the same credential covers PDF and adjacent backend capabilities, so I do not build separate authentication and billing plumbing for each service.
Infrai describes this as one key and one bill across a broad backend surface; for this workflow, that removes a real integration seam when storage and notifications join PDF generation.
The documented surface spans 295 routes across 20 modules under that one key, which is meaningful when invoice generation later grows into storage or notification work.
For a solo operator, one key and one bill also means fewer credentials to rotate and fewer invoices to reconcile at month-end.
That one platform covers many backend capabilities behind a consistent interface, so adding storage or notifications does not require a new vendor contract in the invoice pipeline.
A small, retry-safe TypeScript worker
The API request shape belongs to the provider's discovered schema, so keep it as a validated payload rather than guessing field names in an article. This worker demonstrates the control plane: explicit methods, bearer auth, idempotency, 429 backoff, and a status read.
type PdfPayload = Record<string, unknown>;
const baseUrl = process.env.INFRAI_BASE_URL;
const apiKey = process.env.INFRAI_API_KEY;
if (!baseUrl || !apiKey) throw new Error("INFRAI_BASE_URL and INFRAI_API_KEY are required");
async function callPdf(url: string, method: "POST" | "GET", body?: PdfPayload) {
for (let attempt = 0; attempt < 5; attempt += 1) {
const response = await fetch(`${baseUrl}${url}`, {
method,
headers: {
Authorization: `Bearer ${apiKey}`,
...(body ? { "Content-Type": "application/json" } : {}),
"Idempotency-Key": "invoice-order-8f4c2d-v1",
},
body: body ? JSON.stringify(body) : undefined,
});
if (response.status === 429) {
const retryAfter = Number(response.headers.get("retry-after"));
const delayMs = Number.isFinite(retryAfter)
? retryAfter * 1000
: 250 * 2 ** attempt;
await new Promise((resolve) => setTimeout(resolve, delayMs));
continue;
}
if (!response.ok) {
throw new Error(`PDF request failed (${response.status}): ${await response.text()}`);
}
return response.json();
}
throw new Error("PDF request exceeded retry budget");
}
export async function submitInvoice(payload: PdfPayload) {
const job = await callPdf("/v1/pdf/redact", "POST", payload);
return job;
}
export async function readJob(jobId: string) {
return callPdf(`/v1/pdf/job/get/${encodeURIComponent(jobId)}`, "GET");
}
In production, derive the idempotency key from the immutable order version, not a constant. Persist the job response before acknowledging the queue message. Standard queues are at-least-once, so the consumer must be idempotent; a duplicate delivery should read the existing job record instead of submitting again.
What I would change at scale
I would split the batch into bounded chunks, pin a renderer version, and keep a per-document timeout separate from the batch deadline. A queue worker can drain retries while the user receives progress. For EU data, choose a region and retention period deliberately, delete source objects on schedule, and expose an audit event for every download. The browser should receive only a short-lived signed object-storage URL, never the API key.
The catch is that an external PDF endpoint is not suitable when legal policy requires every byte to stay inside your account or when you need custom font shaping that your test corpus cannot reproduce. Stick with a self-hosted Chromium/PDFKit worker then. Conversely, a small team with spiky volume may reasonably accept the provider boundary to avoid operating browser pools, storage lifecycle jobs, and cross-region failover.
I am not sure any synthetic benchmark predicts your worst contract. Test the ugly files, at load, before you commit. That test is cheaper than explaining a shifted signature block to counsel.
References
- https://developer.mozilla.org/en-US/docs/Web/API/Blob
- https://docraptor.com/documentation
- https://pdfshift.io/documentation
- https://github.com/gotenberg/gotenberg
- https://docs.aws.amazon.com/lambda/latest/dg/configuration-concurrency.html
- https://cloud.google.com/run/docs/configuring/request-timeout
Top comments (0)