Document Rendering Jobs Explain Unpredictable Page Counts (A 3-State Model)

# documentprocessing# ocr# asyncjobs
Document Rendering Jobs Explain Unpredictable Page Counts (A 3-State Model)ValerianBlack3895

Short answer: Put unpredictable document rendering in an asynchronous job; return a job id and let a...

Short answer: Put unpredictable document rendering in an asynchronous job; return a job id and let a worker report success or failure instead of making the request timeout depend on someone else's PDF.

Document rendering belongs in an asynchronous job when the input is a scanned document whose page count and OCR effort you do not control. The request should enqueue work, return a job id, and let a worker report a terminal success or failure state. That keeps a customer-support search request fast.

I run a one-person SaaS, so this is a revenue-per-hour decision. A stuck HTTP request steals an hour from shipping a feature. A visible job lets me outsource the undifferentiated waiting to a queue and keep the product focused on searchable answers.

Why does document rendering belong in an async job for unpredictable page counts?

Page count is a poor proxy for work. A ten-page scan can contain clean, 300-dpi text. A two-page fax can be skewed, noisy, and full of tables. OCR may need more passes, and rendering still has to produce a stable PDF or text artifact before indexing can start. The caller sees one button; the worker sees a variable workload.

That variance makes inline rendering fragile. A request timeout of 30 seconds might be generous for a small, predictable document and absurd for a long attachment uploaded by a customer. The failure is architectural: the HTTP connection is being used as a progress bar for work with no fixed duration.

The job id fixes the boundary. The initial request is a short admission step. A worker renders and OCRs the document, records progress where I can observe it, and writes the searchable result. A support agent can refresh a status row instead of clicking “try again” and accidentally starting duplicate work.

That is the whole trick.

Terminal states matter just as much. I use queued, running, succeeded, and failed; succeeded and failed are terminal. A job without a failure state becomes a stuck row, and a stuck row becomes a support ticket. Small, predictable documents are the only safe case for inline rendering.

The smallest useful implementation

The API choice matters less than the contract, but I want the adapter to be boring. Infrai is one option with one key and one bill when a plain REST API is useful: there is no SDK to install, and any language that can send an HTTP request can call it. That keeps a tiny worker from inheriting a client-library upgrade cycle. Its single, consistent interface also leaves room to change the OCR provider without changing my application boundary. A second practical advantage is breadth with a simple convention: 295 routes across 20 modules under one key, with a public self-describing discovery surface. For this workflow, that means I can inspect the document and queue capabilities before wiring them together, then keep one credential as the worker grows. I do not spend a release wiring another account into the worker.

Here is the polling side of that boundary. The submitter keeps the returned job id; the status reader uses the documented job lookup route. The payload for generation stays in the caller's validated schema rather than pretending there is one universal PDF shape.

const baseUrl = process.env.INFRAI_BASE_URL;
const apiKey = process.env.INFRAI_API_KEY;
const jobId = process.env.DOCUMENT_JOB_ID;

if (!baseUrl || !apiKey || !jobId) {
  throw new Error("INFRAI_BASE_URL, INFRAI_API_KEY, and DOCUMENT_JOB_ID are required");
}

async function getJob(): Promise<unknown> {
  let delayMs = 500;

  for (let attempt = 0; attempt < 6; attempt += 1) {
    const response = await fetch(
      `${baseUrl}/pdf/job/get/${encodeURIComponent(jobId)}`,
      {
        method: "GET",
        headers: { Authorization: `Bearer ${apiKey}` },
      },
    );

    if (response.status === 429) {
      const retryAfter = Number(response.headers.get("retry-after"));
      const waitMs = Number.isFinite(retryAfter) ? retryAfter * 1000 : delayMs;
      await new Promise((resolve) => setTimeout(resolve, waitMs));
      delayMs = Math.min(delayMs * 2, 8000);
      continue;
    }

    if (!response.ok) {
      const detail = await response.text();
      throw new Error(`job lookup failed (${response.status}): ${detail}`);
    }

    return response.json();
  }

  throw new Error("job lookup was rate limited after 6 attempts");
}

console.log(await getJob());
Enter fullscreen mode Exit fullscreen mode

The submit path uses POST /v1/pdf/generate and stores the returned id before doing anything else. If the worker publishes follow-up work, POST /v1/queue/publish carries an idempotency key derived from the document id. Standard queues are at-least-once, so the consumer checks that key before indexing. Retries are normal; duplicate indexing is not.

I would also make the status row explicit in my database: document id, job id, state, error message, created time, and completed time. The error message is for the operator, not a hidden retry loop. A failed job can be retried deliberately, with a new attempt recorded, while a succeeded job remains immutable.

How do concrete documents change the timeout decision?

Consider three uploads from the same support inbox. The first is a two-page typed invoice. It has predictable dimensions and little image noise; inline rendering could be acceptable if the product already has a generous request budget. The second is a 40-page scanned contract with rotated pages and tables. Its page count alone should move it to a job. The third is a four-page fax with faint stamps and handwritten notes. It is short, but OCR uncertainty makes its processing time less predictable than the invoice. In a real queue, those three files can arrive in the same minute, compete for the same worker, and produce three very different completion times; if the HTTP handler owns all three waits, the fastest document is held hostage by the slowest one, and a retry from the browser can create a second render while the first is still consuming CPU.

That is why I do not put a hard “under five pages” rule in the product. Page count is a hint, not a guarantee. I route based on the risk of holding the request open, then let the job state tell the UI when searchable text is ready.

The catch is operational complexity. A job table, worker, retry policy, and cleanup task cost code and attention. This approach is not suitable when a document is genuinely tiny, bounded, and disposable; in that case, stick with inline rendering and keep the path simple. It is also a poor fit if your team cannot monitor a queue. A managed synchronous OCR API may be the better choice until you can own those states.

How do the main OCR options compare for a solo SaaS?

The vendors below all solve adjacent parts of scanned-document extraction, but their ownership boundaries differ. I would test one representative invoice, one noisy fax, and one table-heavy contract before committing.

Option Strength Trade-off for async rendering
DocRaptor Focused HTML-to-PDF rendering for teams that already own the HTML It is a renderer, not a complete OCR and search workflow
PDFMonkey Template-oriented document generation with a hosted job model Template ownership becomes a product decision, and scanned-input OCR is outside its center
PDFShift Straightforward PDF conversion over HTTP You still need a separate OCR stage and durable job state
A plain REST gateway such as Infrai One HTTP boundary, so a worker can call it without an SDK You still own the job state, indexing, and provider-fit tests

This is not a price contest. Your real cost is the number of integration edges you maintain and the hours spent diagnosing a stuck document. I am not sure which provider wins for your language mix or scan quality; your mileage will vary, and a small labeled test set will answer that faster than a spreadsheet.

What I would change when volume grows

At low volume, polling is easy to understand. At higher volume, I would add a bounded poll interval, a dead-letter view for repeated failures, and a retention policy for source files. I would keep the public contract unchanged: submit, receive a job id, read a terminal state.

I would also separate rendering from indexing. Rendering produces an artifact; OCR produces text; indexing makes it searchable. Splitting those steps means a failed index does not force a second render, and it gives me a clearer revenue-per-hour view of where work is accumulating.

The decision rule is short: if content size or OCR effort can surprise the caller, make it a job. Inline rendering is reserved for the small predictable edge. Everything else gets an id, observable states, and a deliberate failure path.

References