Large, multi-page PDFs were timing out partway through OCR processing — not surprising on its own, except that every retry restarted the entire document from page one. A document that failed at page 80 out of 100 would time out again at roughly the same point, every time. Zero-progress retries, indefinitely.
The actual bug wasn't the timeout
Timeouts on large documents are expected; you plan around them. The real problem was that job state lived only in memory for the duration of a single run. Nothing persisted between attempts, so "resume" wasn't possible — only "restart."
Checkpointing per page, resuming from the DB
The fix was to make progress durable instead of ephemeral:
for (const page of pages) {
if (await isPageProcessed(documentId, page.number)) continue;
await processPage(page);
await markPageProcessed(documentId, page.number);
}
Each page's completion gets written to the database as it happens. When a job times out and retries, it queries what's already done and picks up from the next unprocessed page — instead of redoing work that already succeeded.
Outcome
Retries became genuinely incremental. A 100-page document that used to fail the same way on every attempt now finishes in a handful of retries, each one covering only the pages that hadn't succeeded yet.