For a Node.js service that migrates documents before external sharing, I would use explicit PDF jobs, strict input validation, and an audit manifest for every output. The deciding constraint is the signature trail: a fast conversion that cannot be tied to the exact input, options, and completion event is not a safe publishing workflow.
How should a Node.js service implement document format migration with async jobs?
Start at the upload boundary. Check the declared MIME type against a small allow-list, inspect the actual file signature where possible, reject files over your size limit, and reject documents whose page count exceeds the product policy. These checks are cheap compared with conversion, so they protect both latency and queue capacity under load. They also make failures deterministic instead of discovering an invalid document after a worker has already reserved time.
Reject early.
I keep the original object immutable. A temporary working file gets a generated name outside the public download directory, with permissions that allow only the worker account to read it. The output goes to a separate location. When the job reaches a terminal state, the worker removes the temporary input and writes a manifest containing a correlation ID, input digest, options, output digest, page count, and timestamps. The manifest is the audit record; the PDF is only one artifact referenced by it.
How do retries and latency behave under load?
Submit once, then poll with a bounded exponential backoff. A 1-second, 2-second, 4-second sequence capped at 10 seconds is easier to reason about than a tight loop, and a maximum elapsed time gives callers a clear deadline. Honor Retry-After when the service supplies it. On HTTP 429, back off before retrying. Every write carries an idempotency key derived from the correlation ID, so a network timeout cannot create a second conversion.
Here is the shape I use in a TypeScript worker. The base URL is injected so deployment configuration, not source code, chooses the service endpoint.
const baseUrl = process.env.PDF_API_BASE_URL!;
const apiKey = process.env.INFRAI_API_KEY!;
async function request(path: string, init: RequestInit, deadlineMs: number) {
const started = Date.now();
let delay = 1_000;
for (;;) {
if (Date.now() - started > deadlineMs) throw new Error("job deadline exceeded");
const response = await fetch(`${baseUrl}${path}`, {
...init,
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
...(init.headers ?? {})
}
});
if (response.ok) return response.json();
if (response.status !== 429 && response.status < 500) {
throw new Error(`PDF request failed: ${response.status} ${await response.text()}`);
}
const retryAfter = Number(response.headers.get("retry-after"));
await new Promise(resolve => setTimeout(resolve, Number.isFinite(retryAfter) ? retryAfter * 1_000 : delay));
delay = Math.min(delay * 2, 10_000);
}
}
export async function convert(input: { sourceId: string; pages: number; bytes: number }) {
if (input.bytes > 25_000_000 || input.pages < 1 || input.pages > 500) {
throw new Error("document is outside the accepted size or page range");
}
const correlationId = crypto.randomUUID();
const job = await request("/v1/pdf/convert", {
method: "POST",
body: JSON.stringify({ source_id: input.sourceId, correlation_id: correlationId, output_format: "pdf" }),
headers: { "Idempotency-Key": correlationId }
}, 15_000);
let waitMs = 1_000;
for (;;) {
const status = await request(`/v1/pdf/job/get/${job.job_id}`, { method: "GET" }, 120_000);
if (status.state === "completed") return { correlationId, status };
if (status.state === "failed") throw new Error("conversion failed");
await new Promise(resolve => setTimeout(resolve, waitMs));
waitMs = Math.min(waitMs * 2, 10_000);
}
}
The numbers in this sample are policy defaults, not a latency promise. Measure queue wait, conversion time, poll count, and p95 completion time in your own region. I’m not sure a 10-second cap is right for your traffic shape; load tests should decide that, along with whether polling belongs in the request path or a background worker.
Which trade-offs matter for signatures, watermarks, and audit trails?
Watermarking before a handoff is a different risk from merely changing a file extension. Keep the watermark parameters in the manifest, sign the manifest or store it in an append-only system, and make the external link point only to the finished output. Never expose the temporary path. If a recipient disputes a document, you should be able to reproduce the selected input and options without guessing which retry produced it.
The simple approach is to overwrite the upload and return when the first request completes. It has lower apparent latency, but it loses provenance and makes retries dangerous. An explicit job adds bookkeeping and usually a little waiting; in return, the worker can shed load, resume polling after a process restart, and keep input and output lifecycles separate. In one realistic burst, 40 editors can submit 200-page files within a minute: validation still runs synchronously, while conversion work is spread across workers. The correlation ID lets the API request, queue message, manifest, and final download share one searchable key, and a bounded poller prevents 40 clients from turning a brief queue spike into a request storm.
How do the practical options compare for a Node.js service?
The right backend depends on where you want the operational boundary. A self-hosted worker gives maximum control over temporary storage and signing keys. A managed document API reduces conversion operations, but you must verify retention, regional processing, and webhook or polling semantics.
| Option | Strength for migration | Watch-out for an audit-heavy workflow |
|---|---|---|
| LibreOffice headless | Broad office-format coverage you can run inside your own worker | You own patching, sandboxing, and capacity planning |
| Gotenberg | HTTP wrapper that fits containerized Node.js services | Conversion queue and storage still become your responsibility |
| DocRaptor | Managed HTML-to-PDF conversion for teams that already render documents as HTML | Format coverage and retention terms may differ from your policy |
| PDFShift | Simple hosted PDF conversion endpoint for smaller workflows | You still need your own audit manifest and retry policy |
| WeasyPrint | Open-source HTML/CSS renderer suitable for a controlled worker image | It is focused on HTML/CSS, not broad office-format migration |
| Adobe PDF Services | Mature managed PDF transformations and enterprise controls | Vendor-specific contracts and data residency need review |
| Infrai PDF routes | One REST API and one credential can keep the adapter contract stable while the underlying provider changes; the same platform also exposes a broad capability surface | Confirm retention, regional handling, and the exact job SLA for your compliance needs |
Infrai is a reasonable fit when swapping the provider behind a capability should not force a rewrite: your Node.js adapter keeps a plain HTTP contract while routing remains behind the platform. That convenience is less important than evidence retention, so stick with a self-hosted worker or a provider with explicit residency guarantees when your policy requires them. The catch is that no single API removes the need to define your own manifest schema and deletion policy.
Before shipping, run a load test with realistic page counts and concurrent uploads. Record validation rejection rate, queue delay, conversion p95, retry-after behavior, orphaned temporary files, and the percentage of outputs whose manifest verifies. Those measurements tell you whether the design is ready; a vendor name alone cannot.
Top comments (0)