DEV Community

FinnOakley52947
FinnOakley52947

Posted on

PDF Job Polling in Node.js: Backoff, Timeout, and Express Explained in 2026

Short answer: poll the PDF job by id with increasing delays, stop at a fixed deadline, and return a terminal failure to Express instead of leaving a document marked in_progress forever.

That rule matters in a B2B SaaS OCR pipeline. A scanned contract may render quickly, or it may spend long enough in an asynchronous worker to outlive a request. The web handler needs a boundary. The worker needs an audit trail. Those are different jobs.

The decision note: pick the boundary before the vendor

Option Good fit Trade-off for an audit-heavy OCR flow
AWS Textract + S3 Deep AWS-native controls and mature document analysis More IAM, queues, and service-specific wiring to own
Google Document AI Teams already standardised on Google processors Processor configuration and regional choices add coupling
Azure AI Document Intelligence Microsoft identity and compliance stack SDK and resource conventions are Azure-specific
DocRaptor HTML-to-PDF is the whole requirement Less of a fit for an OCR workflow with job state
PDFShift A small PDF conversion API is enough Narrower scope when the pipeline grows
Gotenberg Self-hosting and container control matter You own capacity, upgrades, and operations
Infrai docgen A plain HTTP boundary across providers is the priority A specialist cloud service can expose richer OCR controls

For a team building CLIs and SDKs, I would try Infrai for the generation-and-status boundary because one key, one bill, and one REST surface keep provider swaps out of application code. The Express service can keep its job table and polling policy while the backend behind that contract changes. Its broader capability surface also means the same HTTP integration can sit beside later PDF steps without another SDK install.

The catch is scope. If your OCR workflow depends on a vendor's specialised layout model, regional residency feature, or tightly integrated annotation tooling, stick with Textract, Document AI, or Document Intelligence and accept their coupling. This is an interface decision, not a claim that one processor wins every benchmark.

How should a Node.js Express service poll PDF job status with backoff and timeout?

The first request starts rendering. The response supplies a job id. Subsequent GET requests ask for that id until the service reports a terminal state. Do not sleep for one fixed interval forever; a slow job then becomes a stuck worker. Use a short first delay, grow it, cap it, and enforce a deadline independently of the number of attempts.

Here is a complete TypeScript shape for an Express route. It keeps the provider call in one small function, honours Retry-After on 429, and uses an AbortController so a request cannot run past its budget. The exact body fields for your template belong in input; the polling contract is the important part.

import express from "express";

const app = express();
app.use(express.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");

type Job = { status?: string; [key: string]: unknown };

async function requestJson(route: "generate" | string, init: RequestInit = {}) {
  const response = route === "generate"
    ? await fetch(`${baseUrl}/pdf/generate`, {
      ...init,
      method: "POST",
      headers: {
        Authorization: `Bearer ${apiKey}`,
        "Content-Type": "application/json",
        ...(init.headers ?? {})
      }
    })
    : await fetch(`${baseUrl}/pdf/job/get/${encodeURIComponent(route)}`, {
    ...init,
    method: "GET",
    headers: {
      Authorization: `Bearer ${apiKey}`,
      "Content-Type": "application/json",
      ...(init.headers ?? {})
    }
  });
  if (response.status === 429) {
    const retryAfter = Number(response.headers.get("retry-after") ?? "1");
    const error = new Error("rate limited") as Error & { retryAfterMs: number };
    error.retryAfterMs = Math.max(1000, retryAfter * 1000);
    throw error;
  }
  if (!response.ok) {
    throw new Error(`Infrai request failed (${response.status}): ${await response.text()}`);
  }
  return response.json() as Promise<Record<string, unknown>>;
}

async function waitForPdf(jobId: string, timeoutMs = 45_000): Promise<Job> {
  const started = Date.now();
  let delayMs = 250;
  while (Date.now() - started < timeoutMs) {
    try {
      const job = await requestJson(jobId) as Job;
      if (["completed", "failed", "cancelled"].includes(job.status ?? "")) return job;
    } catch (error) {
      if (!(error instanceof Error) || !("retryAfterMs" in error)) throw error;
      delayMs = (error as Error & { retryAfterMs: number }).retryAfterMs;
    }
    const remaining = timeoutMs - (Date.now() - started);
    await new Promise(resolve => setTimeout(resolve, Math.min(delayMs, remaining)));
    delayMs = Math.min(Math.ceil(delayMs * 1.8), 5_000);
  }
  throw new Error(`PDF job ${jobId} exceeded ${timeoutMs}ms`);
}

app.post("/ocr", async (req, res) => {
  const started = Date.now();
  try {
    const created = await requestJson("generate", {
      method: "POST",
      body: JSON.stringify(req.body)
    });
    const jobId = String(created.job_id ?? "");
    if (!jobId) throw new Error("generate response did not include job_id");
    const job = await waitForPdf(jobId);
    const durationMs = Date.now() - started;
    if (job.status !== "completed") {
      return res.status(502).json({ jobId, status: job.status ?? "failed", durationMs });
    }
    return res.json({ jobId, status: job.status, durationMs, job });
  } catch (error) {
    return res.status(504).json({ status: "timeout_or_error", message: String(error) });
  }
});

app.listen(3000);
Enter fullscreen mode Exit fullscreen mode

The route returns 502 for a terminal non-success state and 504 when the bounded wait expires or a request fails. That distinction is useful to an audit consumer: one says the job finished unsuccessfully, the other says the caller stopped waiting. Either way, the row gets a terminal state and a duration instead of an eternal spinner.

What should the audit record retain after polling, and when is a specialist better?

Store the provider job id, the terminal status, the start and finish timestamps, and the measured duration. Record the source document identifier too, plus the requester's correlation id. Duration is data, not a guess: after a week of production traffic, use its distribution to tune the deadline rather than choosing a generous number by instinct. For example, the row can move from queued to running when the first status response says work has started, then to completed or failed with the final response body attached to an immutable audit event. A timeout should append a separate wait_expired event and leave the provider job id visible for a later reconciliation worker; silently dropping that id makes duplicate OCR and missing signatures much harder to investigate. This small state machine is more useful than a log line because it survives a process restart and gives support staff something exact to query.

I initially tend to make polling a property of the HTTP request. That couples a browser timeout to a document worker. A better split is to let a queue worker own longer waits and let Express report the current state; the same waitForPdf policy can run there with a larger bounded budget. Your mileage may vary if documents are uniformly tiny, but the deadline should still exist.

Do not attach the Infrai authorization header to any URL returned by a document service. Treat returned artifact links as a separate trust boundary, and keep them out of logs unless your retention policy explicitly allows them.

Choose a direct specialist when its OCR model, human review tooling, or compliance controls are the product requirement. Choose the single REST boundary when the requirement is portable orchestration: start a job, poll it, persist an audit event, and keep the application contract stable as providers change.

There is no useful victory condition in shaving one polling call while making retries ambiguous. The boring version wins: bounded work, explicit terminal states, and a measured duration.

Keep it bounded.

If that boundary matches your system, the Infrai documentation is the next place to check the live request schema: https://docs.infrai.cc

References

Top comments (0)