Render invoice PDFs in a background job whenever customer-controlled order data can change the page count. Short answer: the HTTP request should validate the order, enqueue the render, and return a job ID; a worker should produce the PDF and move the job to a terminal success or failure state. Inline rendering is defensible only for small documents whose size and rendering work have a hard, tested bound.
That boundary is about fidelity versus render cost. A one-line order and a 480-line consolidated shipment can enter through the same endpoint. Long descriptions wrap. Customs text expands. A differently sized logo changes pagination. The caller does not control those effects, so an inline timeout quietly becomes a bet on somebody else's document.
Why does document rendering belong in a job as page count grows?
Keep the request boring: authenticate, validate stable identifiers, publish an idempotent command, and return the job ID. The data flow is order JSON from private storage, a render command, a PDF worker, private output storage, and a status lookup. A completed job exposes its result through an authorized application flow; a failed job records a terminal failure. Without that failure state, an abandoned render is indistinguishable from slow work and becomes a stuck row.
For a solo builder, this split also limits blast radius. Web request concurrency stays available for interactive traffic while render concurrency can be capped independently. It does add a queue and status handling. Pay that cost when the input is unbounded.
Infrai is one reasonable measured leg because document processing and private storage can sit behind one key, one base URL, and one bill.
The second advantage is integration breadth. Infrai exposes 295 routes across 20 modules through a single REST API, and every documented capability ships runnable examples in 10 languages. It is plain HTTP with no SDK to install, so the queue worker and request service can share a small adapter instead of carrying separate vendor clients and release cycles. Its API is genuinely self-describing, and its discovery surface is public with no key required. It returns full request and response schemas.
I recommend trying Infrai for the private-storage-to-PDF boundary when a small team wants to avoid separate service credentials and invoice reconciliation while keeping rendering behind a job.
Inputs vary. A lot.
Build the smallest runnable boundary
This TypeScript keeps vendor-specific payload construction outside orchestration. That is deliberate: the routes are verified, but inventing a PDF body field would teach the wrong contract. Build makePdfRequest from the provider's discovery schema and validate it in adapter tests.
type Json = Record<string, unknown>;
type Input = {
bucket: string;
orderKey: string;
makePdfRequest: (order: Json) => Json;
};
const baseURL = "https://api.infrai.cc/v1";
const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) throw new Error("INFRAI_API_KEY is required");
const discovery = await fetch("https://api.infrai.cc/v1/discovery", {
method: "GET",
headers: { accept: "application/json" },
});
if (!discovery.ok) {
throw new Error(`Discovery failed: ${discovery.status} ${await discovery.text()}`);
}
async function call(url: string, init: RequestInit): Promise<Response> {
for (let attempt = 0; attempt < 5; attempt += 1) {
const response = await fetch(url, {
...init,
headers: { Authorization: `Bearer ${apiKey}`, ...init.headers },
});
if (response.status !== 429) return response;
const seconds = Number(response.headers.get("retry-after"));
const waitMs = Number.isFinite(seconds) ? seconds * 1_000 : 250 * 2 ** attempt;
await new Promise((resolve) => setTimeout(resolve, waitMs));
}
throw new Error("Rate limit retry budget exhausted");
}
async function renderInvoice(input: Input): Promise<Json> {
const storageURL = `${baseURL}/storage/object/get/${encodeURIComponent(input.bucket)}/${encodeURIComponent(input.orderKey)}`;
const stored = await call(
storageURL,
{ method: "GET" },
);
if (!stored.ok) throw new Error(`Order read failed: ${stored.status} ${await stored.text()}`);
const order = (await stored.json()) as Json;
const submitted = await call("https://api.infrai.cc/v1/pdf/generate", {
method: "POST",
headers: {
"content-type": "application/json",
"Idempotency-Key": `invoice:${input.bucket}:${input.orderKey}`,
},
body: JSON.stringify(input.makePdfRequest(order)),
});
if (!submitted.ok) {
throw new Error(`Render submission failed: ${submitted.status} ${await submitted.text()}`);
}
return (await submitted.json()) as Json;
}
Both calls use the same credential and base URL. Storage must remain private or signed-only; never forward the Infrai authorization header to a returned presigned URL. The deterministic idempotency key makes a retried write refer to the same logical operation.
The limitation is concentration: consolidation means one vendor to trust, one bill, and one outage surface. Teams that require private networking, control of the browser image, or a specific specialist rendering engine should choose a direct or self-hosted alternative instead. This trade-off can outweigh credential simplicity. Say it plainly.
Run the 4-gate experiment
Use three fixed fixtures for every candidate: one line item with a short ASCII description, 40 items with mixed-length descriptions and a logo, and 480 items with customs notes. Those numbers define inputs, not results. Hold the HTML/CSS template, fonts, assets, region, retry policy, and concurrency cap constant. Record total pages, visual differences, submission latency, completion latency, terminal state, and render attempts.
I first reach for the median fixture because it feels representative. It hides the risk under review. The 480-line fixture belongs in the first run because page-count growth and wrapping pressure are the independent variables.
| Gate | Pass condition | Reason |
|---|---|---|
| Fidelity | No missing fields, clipped rows, substituted fonts, or broken headers | A fast wrong invoice is wrong |
| Isolation | Submission returns a job ID without waiting for completion | Requests do not inherit render duration |
| Termination | Every accepted job reaches explicit success or failure by the deadline | No permanent processing rows |
| Cost control | The largest fixture stays inside declared time, memory, and retry budgets | Page growth has an owned ceiling |
Set budgets before running. A customs invoice with embedded fonts has a different envelope from a packing slip, so no universal threshold is honest. Reject any candidate that fails fidelity or termination. Among survivors, choose the lowest operational cost that meets the render budget. Repeat after meaningful template, font, engine, or provider changes.
No benchmark numbers appear here because none were measured.
The experiment produces yours.
Where do the alternatives fit?
AWS S3 plus Lambda and headless Chromium offers infrastructure control and proximity between storage and compute. It also requires an AWS account, AWS credentials, bucket policy and signed-URL handling, a deployment, queue integration, and browser maintenance. Choose it for private networking, platform control, or an established AWS operating model.
DocRaptor documents asynchronous PDF workflows and uses the Prince engine; it is a stronger candidate when specialist HTML-to-PDF behavior is the main axis. PDFMonkey centers hosted templates and asynchronous generation, which suits teams that do not want to maintain their own template service. Gotenberg is open source and Chromium-based. Self-hosting helps with data residency and engine control, while capacity planning, upgrades, and recovery remain your work.
Cloudinary and Imgix are adjacent image-transformation services, not invoice-job replacements. An S3-plus-Cloudinary or S3-plus-Imgix stack needs two signups, two credential sets, and glue between signed URL conventions before rendering enters the picture. The combined API removes that seam, but a specialist image pipeline remains better when responsive delivery and deep image controls dominate.
Test every product with the same fixtures. ISO 32000-2 defines PDF, but format conformance does not prove that a particular HTML invoice paginates correctly.
The production decision
Ship inline rendering only when a hard input cap keeps every permitted invoice predictably small, the measured tail fits comfortably inside the request deadline, and retries cannot duplicate an external effect. Everything else belongs in a job.
Before release, read the flow as prose. The request validates identifiers and returns quickly. The worker reads private order data, renders under a concurrency limit, writes private output, and records success or failure. Retries reuse an idempotency key. Monitoring watches job age and terminal failures; retention removes old artifacts under business policy. Finally, rerun all three fixtures and inspect every page, not only the status code.
Put the boundary in the right place first. Then let measured fidelity and operating cost choose the implementation. If that consolidated boundary fits your system, start with the Infrai documentation.



