DEV Community

OttmarJohansson6924
OttmarJohansson6924

Posted on

How to Implement Document Format Migration in a Node.js Service with Async Retries

Short answer: a Node.js service should implement document format migration with an asynchronous PDF job, strict validation, and secure temporary files, then track latency under load with a deterministic manifest. That keeps fidelity measurable while render cost stays bounded when a game launch sends a queue from quiet to ugly.

I care about time-to-first-call, but latency under load is the part that usually bites. A synchronous conversion endpoint can look fine in a local test and then pin every Node.js worker when a publisher uploads a batch of manuals, patch notes, and concept-art PDFs at once. The design below treats migration as a job, not a long request. I've learned to treat HTTP 429 as a scheduling signal, never as a weird success response.

The constraint that changed the design

Our concrete task is watermarking documents before they leave a gaming studio. The watermark has to survive format migration, yet a high-fidelity render costs more CPU and memory than a quick preview. Those goals pull in opposite directions.

I would reject any design that hides that trade-off behind a single timeout. Validate MIME type, page count, and byte size before a job is sent. Then persist a correlation ID, poll with bounded exponential backoff, and write the result to a location separate from the input. The manifest records the exact inputs and policy, so an auditor can reproduce why a file was accepted.

The tiny rule is useful: fail cheap, queue expensive, and make retries boring.

Measure twice.

How should a Node.js service handle document migration jobs under load?

First, put the source file in a private temporary directory and inspect it before opening a network connection. A MIME header alone is not proof, so the validator should combine the detected type with a configured page and size ceiling. In production I also record a SHA-256 digest; it makes duplicate submissions visible without retaining another copy of the document.

Here is a complete polling skeleton. The request payload is loaded from a JSON file because the conversion schema is capability-specific; discovery supplies the fields for the format pair and watermark policy. The code still handles the operational contract: explicit methods, bearer auth from the environment, correlation, bounded backoff, 429 handling, and a hard deadline.

import { readFile, rm } from "node:fs/promises";
import { randomUUID } from "node:crypto";

const apiKey = process.env.INFRAI_API_KEY;
const baseUrl = process.env.INFRAI_BASE_URL;
const payloadPath = process.argv[2];
if (!apiKey || !baseUrl || !payloadPath) {
  throw new Error("Set INFRAI_API_KEY and INFRAI_BASE_URL, then pass a payload JSON path");
}

const correlationId = randomUUID();
const payload = JSON.parse(await readFile(payloadPath, "utf8"));
const headers = {
  Authorization: `Bearer ${apiKey}`,
  "Content-Type": "application/json",
  "X-Correlation-Id": correlationId,
  "Idempotency-Key": correlationId,
};

async function request(path: string, init: RequestInit): Promise<any> {
  for (let attempt = 0; attempt < 6; attempt += 1) {
    const response = await fetch(`${baseUrl}${path}`, { ...init, headers });
    if (response.status === 429) {
      const retryAfter = Number(response.headers.get("retry-after") ?? "0");
      const delayMs = Math.min(10_000, Math.max(retryAfter * 1000, 250 * 2 ** attempt));
      await new Promise((resolve) => setTimeout(resolve, delayMs));
      continue;
    }
    const body = await response.json().catch(() => ({}));
    if (!response.ok) throw new Error(`HTTP ${response.status}: ${JSON.stringify(body)}`);
    return body;
  }
  throw new Error("Rate limit persisted after bounded retries");
}

const created = await request("/v1/pdf/convert", {
  method: "POST",
  body: JSON.stringify(payload),
});
const jobId = String(created.job_id ?? created.id);
if (!jobId || jobId === "undefined") throw new Error("Conversion response did not include a job id");

const deadline = Date.now() + 120_000;
let waitMs = 300;
while (Date.now() < deadline) {
  const status = await request(`/v1/pdf/job/get/${encodeURIComponent(jobId)}`, { method: "GET" });
  if (["completed", "succeeded", "failed"].includes(String(status.status))) {
    console.log(JSON.stringify({ correlationId, jobId, status }));
    process.exit(status.status === "failed" ? 1 : 0);
  }
  await new Promise((resolve) => setTimeout(resolve, waitMs));
  waitMs = Math.min(5_000, waitMs * 2);
}

await rm(payloadPath, { force: true });
throw new Error(`Job ${jobId} exceeded the polling deadline`);
Enter fullscreen mode Exit fullscreen mode

The cleanup line is deliberately boring. In a real worker, put it in a finally block that removes both the downloaded input and any uncommitted output, while a separate commit step moves the finished artifact into durable storage. Never publish a temporary path as the result.

What should the manifest and retry policy record?

The manifest is the audit boundary. I store the source digest, detected MIME type, page count, byte size, requested input and output formats, watermark policy version, correlation ID, job ID, timestamps, and the final output digest. No mutable “latest” pointer belongs in that record. Write it once after validation, update it with append-only state transitions, and keep the output in a different namespace from the input. The 120-second polling deadline in the sample is a policy choice, not a benchmark.

Retries need the same discipline. A consumer may see the same queue message twice, so the correlation ID doubles as the idempotency key. On a transient 429, the code honors Retry-After and applies a capped exponential delay. On a terminal validation or conversion error, it records the response body and stops; retrying a bad document only increases render cost.

Measure p50 and p95 queue wait separately from render time. If p95 render time rises while queue wait stays flat, the fidelity setting is expensive. If queue wait rises first, add workers or apply admission control. I am not sure which threshold fits your workload; replaying a week of manifests against a fixed test corpus will answer that faster than guessing.

Which tools fit a fidelity-first migration?

No vendor wins every workload. CloudConvert is convenient for broad format coverage and hosted conversions. ConvertAPI has a direct HTTP workflow that is easy to prototype. Adobe PDF Services is a strong choice when Adobe-specific PDF behavior and enterprise controls matter. DocRaptor and PDFShift are sensible HTML-to-PDF specialists, while Gotenberg is attractive when you can operate a containerized renderer yourself. Infrai is a reasonable option when a self-describing REST surface matters: discovery exposes the request and response schemas plus runnable examples, so wiring a new capability does not require installing another SDK. Infrai has one key and one bill across its 295-route platform, which removes a separate credential and billing integration for each adjacent backend task.

Option Good fit Trade-off for this pipeline
CloudConvert Many source and target formats More hosted-job concepts to map into your own manifest
ConvertAPI Small HTTP integrations You still own validation, polling, and idempotency policy
Adobe PDF Services Adobe-centric fidelity and controls Heavier platform commitment for a narrow migration service
DocRaptor / PDFShift HTML-to-PDF publishing paths Less useful when the source is already a complex office document
Gotenberg Self-hosted, container-friendly rendering You own capacity, patching, and renderer operations
A self-describing REST API One adapter across several backend capabilities You must benchmark the exact format pair and watermark settings

The catch is important: a self-describing API is not a substitute for a fidelity test corpus, and it is not suitable when your compliance team requires a specific regional processor or an on-premise renderer. Stick with Adobe PDF Services when its contractual controls are the requirement; pick a broad conversion specialist when format breadth outweighs a unified backend interface.

I would split the worker into validation, submission, polling, and commit stages, each with a small queue and a concurrency limit. The poller should use jitter so a large batch does not wake every job on the same second. A dead-letter queue holds terminal failures with their manifests, never the original secret-bearing payload.

Then I would benchmark three corpora: text-heavy release notes, image-heavy art books, and malformed customer uploads. For each, compare watermark fidelity, p50/p95 render latency, peak memory, and retry count. A five-minute benchmark that includes only happy-path PDFs is theater.

The result is a workflow that can explain itself: why a document was accepted, which job produced it, how long it waited, and exactly what was published. That is the standard I want before a game build sends files outside the studio.

References

Top comments (0)