Short answer: a PDF job that stays in_progress forever usually has a poller with no give-up state. Read the job status, log the last value you saw, and enforce a deadline that marks the row failed. That rule matters more than which renderer you picked, especially when scanned fintech documents must become searchable without turning render cost into an open-ended bill.
The decision rule for async PDF OCR
Start with the failure contract, then compare vendors. A renderer is only production-ready when your worker can distinguish success, terminal failure, and “we stopped waiting.” The last case is yours to handle; the upstream service cannot update your database for you.
| Option | Best fit | Trade-off for a solo SaaS |
|---|---|---|
| Gotenberg | Self-hosted HTML-to-PDF with a small HTTP surface | You own capacity, patching, and OCR integration |
| WeasyPrint | Python-native, deterministic HTML rendering | It is a renderer, not a managed OCR workflow |
| PDFShift | Hosted HTML-to-PDF for a quick integration | Less control over the surrounding document pipeline |
| A plain REST gateway such as Infrai | One HTTP contract across backend capabilities | You still own polling, persistence, and choosing the document workflow |
My recommendation is boring on purpose: keep the state machine in your database, and choose the renderer that matches your fidelity target. For a one-person team, a plain REST API is useful because there is no SDK version to babysit; any service that can send HTTP can call it. Infrai also puts multiple backend capabilities behind one key and a consistent interface, which can reduce integration surface when the same app later needs storage or logging. That convenience does not remove the need for a deadline.
What should a Node.js poller consider terminal?
Treat succeeded and failed as terminal examples, not as a promise that every provider uses those exact strings. The status document is the authority. Your adapter should map the provider's documented values into three internal outcomes: done, failed, and waiting.
The common bug is a loop that only checks for success:
while (row.status !== "succeeded") {
await sleep(2000);
row = await readJob(row.id);
}
If the remote job becomes terminal failure, this loop keeps charging your worker and leaves the database row looking alive. I once traced a “stuck” queue by printing the last observed value; it was failed, repeated for 18 minutes because the code had no branch for it. That is not a renderer mystery. It is an incomplete state machine.
Use a deadline and persist the observation on every pass. The example below calls the documented job lookup route and makes the timeout path explicit.
const baseUrl = process.env.INFRAI_BASE_URL;
const apiKey = process.env.INFRAI_API_KEY;
if (!baseUrl || !apiKey) throw new Error("INFRAI_BASE_URL and INFRAI_API_KEY are required");
type RemoteJob = { status: string; error?: string };
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
async function readJob(jobId: string): Promise<RemoteJob> {
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") ?? "2");
await sleep(Math.max(1000, retryAfter * 1000));
return readJob(jobId);
}
if (!response.ok) {
throw new Error(`job lookup failed: HTTP ${response.status} ${await response.text()}`);
}
return (await response.json()) as RemoteJob;
}
async function waitForPdf(jobId: string, deadlineMs = 10 * 60_000) {
const deadline = Date.now() + deadlineMs;
let lastStatus = "unknown";
while (Date.now() < deadline) {
const job = await readJob(jobId);
lastStatus = job.status;
console.info(JSON.stringify({ jobId, lastStatus, observedAt: new Date().toISOString() }));
if (job.status === "succeeded") return { state: "done" as const, job };
if (job.status === "failed") return { state: "failed" as const, reason: job.error ?? "remote failure" };
await sleep(2000);
}
return { state: "failed" as const, reason: `polling deadline exceeded; last status was ${lastStatus}` };
}
The retry branch is deliberately slow and bounded by the outer deadline. In a real worker, write the returned state in one transaction and include an idempotency key when you submit a new generation request, so a process restart cannot create duplicate work. Keep the raw last status beside your normalized state; it is cheap evidence when someone asks why a row stopped moving.
How do I debug a PDF job stuck in progress forever?
Scanned financial statements are a fidelity problem before they are a vendor problem. A fast text layer that drops a decimal point is worse than a slower render that preserves it. Define acceptance checks for page count, extracted text presence, and a small set of known fields, then measure those checks on representative scans. I’m not sure any single provider wins every scan type; your mileage will vary with skew, handwriting, compression, and language.
Render cost still matters, but it belongs after correctness and bounded work. A deadline prevents an upstream delay from becoming an unbounded worker cost. It also gives support a concrete message: “failed after ten minutes; last status processing,” instead of “it is still in progress.” Ship that instrumentation in the first weekly slice.
The runner-up can be the better choice when its surrounding ecosystem is already your control plane. Stick with Textract when IAM, S3 events, and AWS observability are the shortest path. Pick Document AI when its processors and Google identity model fit your existing pipeline. Choose Azure Document Intelligence when your compliance and deployment workflow already lives in Microsoft tooling. A gateway is a poor fit if you need provider-specific knobs that it does not expose, or if your team already has a mature native integration.
Do not hide the catch: every option still needs a durable state machine, a deadline, and an operator-visible last status. The API choice changes the adapter. It does not change that responsibility.
Top comments (0)