Last reviewed September 1, 2026

How do I process thousands of documents overnight?

Direct answer

Build a bounded queue, not a for-loop. Throughput against a synchronous per-document API comes down to three numbers: your requests-per-minute limit, your per-request latency, and how many requests you run in parallel. Pick a concurrency that keeps you comfortably under the rate limit, make every job idempotent so a retry cannot double-charge or double-post, and record each failure with its request id instead of aborting the whole run at document 4,000.

01

The arithmetic of an overnight run

Do this on paper before you write the worker. Two formulas decide whether your run fits in the window you have.

Wall clock, ignoring failures, is roughly documents multiplied by average latency divided by concurrency. Two thousand documents at 6 seconds each with 8 workers is about 25 minutes. The same job with concurrency 1 is over three hours. Your ceiling on concurrency is the rate limit: concurrency divided by latency in seconds, times 60, must stay under your requests-per-minute allowance.

Then add failures. Assume a small percentage will retry, and that retries are slower than first attempts because they wait. Budget 20% headroom on the window and you will rarely be wrong in the expensive direction.

02

Rate limits and quotas are different failures

Confusing these two is the most common reason a batch job either stalls or burns an entire month of quota in an hour.

A rate limit is transient: too many requests per minute right now. Dokyumi returns 429 with code rate_limited and a Retry-After header; new API keys default to 60 requests per minute, and the configured limit on each key is what applies. Waiting fixes it.

A quota is terminal for the period: the plan’s monthly credits are gone. That surfaces as 429 with code quota_exceeded, or as 402 when a document turns out, after OCR, to need more credits than remain. Retrying does not fix either one — the run should stop and tell somebody. Separately, self-serve plans reject documents over the page ceiling with 413, refunding the reserved credit, so an oversized file fails loudly rather than silently truncating.

03

Idempotency, or how to survive a resumed run

Overnight jobs get interrupted. The machine restarts, the network blips, somebody kills the process at 3 a.m. What matters is what happens when you start it again.

Key every job by something stable — the file’s content hash is ideal, because it is identical across re-runs and immune to filename churn. Record three states per key: pending, succeeded with the response, failed with the reason and request id. On restart, skip anything already succeeded. That single discipline turns a scary re-run into a boring one, and it also prevents paying twice for the same document.

04

Budget in credits, not documents

Credits are page-weighted, so a batch estimate that counts documents will be wrong for anything but single-page files. One credit covers a document of up to 5 pages, and a document consumes one more credit per additional 5 pages. Self-serve plans stop at 50 pages per document.

Work it out with your real page distribution. Two thousand documents averaging seven pages is two credits each — 4,000 credits — which exceeds the 3,000 credits included with Growth at $499 a month. There is no overage billing: quotas are hard caps, so a run that needs more than the plan includes is an Enterprise conversation to have before the run rather than at 2 a.m.

Two practical notes. Repeat submissions of an identical file can reuse cached OCR in standard mode, which the response reports as meta.ocr_cached — every extraction still consumes credits. And meta.credits_used on each response is the authoritative per-document cost, so log it and you will know your true cost per batch without estimating.

Decision table

Overnight run worksheet

Fill in the middle column with your own numbers. Every row is arithmetic you can do in a minute, and together they tell you whether the run fits the window and the plan.

InputYour numberWhat it decides
Documents in the batch (N)____Everything else scales from this
Average pages per document (P)____Credits per document is ceil(P / 5)
Credits neededN x ceil(P / 5)Whether the batch fits inside the plan quota
Measured latency per request (L seconds)____Measure it on 20 real documents; do not guess
Requests per minute allowed (R)60 by default per keyCaps concurrency: C must be below R x L / 60
Chosen concurrency (C)____Start at 4, raise while 429 rate_limited stays at zero
Estimated wall clockN x L / C secondsAdd 20% for retries and slow documents
Failure budget____ %Above this, stop the run rather than grinding through it

Worked example: 2,000 documents, 7 pages average, 6 second latency, concurrency 8. That is 4,000 credits, roughly 25 minutes of wall clock, and about 80 requests per minute — which is above a default 60 rpm key, so either drop to concurrency 6 or have the key’s limit configured before the run.

Copy-pasteable artifact

A worker pool that behaves under load

Bounded concurrency, idempotency by content hash, correct handling of the transient-versus-terminal distinction, and Retry-After honoured rather than guessed. No dependencies beyond Node.

batch.ts
import { createHash } from 'node:crypto'
import { readFile } from 'node:fs/promises'

type Outcome =
  | { state: 'succeeded'; key: string; result: unknown; credits: number }
  | { state: 'failed'; key: string; reason: string; requestId?: string }

const API = 'https://dokyumi.com/api/v1/extract'
const CONCURRENCY = 6          // keep C x 60 / latency under the key's rpm
const MAX_ATTEMPTS = 4

const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms))

async function extractOne(path: string, schema: string, apiKey: string): Promise<Outcome> {
  const bytes = await readFile(path)
  const key = createHash('sha256').update(bytes).digest('hex')   // idempotency key

  for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
    const form = new FormData()
    form.append('file', new Blob([bytes]), path.split('/').pop() ?? 'document.pdf')
    form.append('schema', schema)

    const res = await fetch(API, {
      method: 'POST',
      headers: { Authorization: 'Bearer ' + apiKey },
      body: form,
    })

    if (res.ok) {
      const body = await res.json()
      return { state: 'succeeded', key, result: body, credits: body?.meta?.credits_used ?? 0 }
    }

    const err = await res.json().catch(() => ({ error: 'HTTP ' + res.status }))

    // Transient: a per-minute rate limit. Wait exactly as long as we are told.
    if (res.status === 429 && err.code === 'rate_limited') {
      const retryAfter = Number(res.headers.get('Retry-After'))
      await sleep(Number.isFinite(retryAfter) ? Math.max(1000, retryAfter * 1000) : 1000 * 2 ** attempt)
      continue
    }
    // Terminal: quota gone (429 quota_exceeded / 402) or the document was rejected (413).
    if (res.status >= 400 && res.status < 500) {
      return { state: 'failed', key, reason: err.code || err.error, requestId: err.request_id }
    }
    // Server-side: back off and try again.
    await sleep(1000 * 2 ** attempt)
  }
  return { state: 'failed', key, reason: 'max attempts exceeded' }
}

export async function runBatch(paths: string[], schema: string, apiKey: string, done: Set<string>) {
  const queue = [...paths]
  const results: Outcome[] = []

  const worker = async () => {
    for (let path = queue.shift(); path; path = queue.shift()) {
      const outcome = await extractOne(path, schema, apiKey)
      // Stop the whole run on a terminal quota failure: retrying cannot help.
      if (outcome.state === 'failed' && outcome.reason === 'quota_exceeded') {
        queue.length = 0
      }
      if (outcome.state === 'succeeded') done.add(outcome.key)
      results.push(outcome)
    }
  }

  await Promise.all(Array.from({ length: CONCURRENCY }, worker))
  const credits = results.reduce((n, r) => n + (r.state === 'succeeded' ? r.credits : 0), 0)
  return { results, credits }
}

The two lines that save the most pain are the quota check that empties the queue, and summing meta.credits_used at the end. One stops a doomed run early; the other tells you exactly what the batch cost without an estimate.

Terminology bridge

This is batch processing with back-pressure, and the stable key is an idempotency key

Running work through a bounded pool so you never exceed a downstream limit is back-pressure; keying each unit of work so a repeat is harmless is idempotency, the same idea payment APIs use to make a retried charge safe. HTTP names the two signals you need: 429 for too many requests, and the Retry-After header that tells you how long to wait instead of guessing.

  • batch processing
  • back-pressure
  • worker pool
  • idempotency key
  • exponential backoff

Follow-up questions

Is there a bulk endpoint that takes many files at once?+
No. Extraction is one document per call, and the response comes back synchronously. Concurrency lives in your client, which is what the worker pool above provides.
Can I raise the requests-per-minute limit?+
New keys default to 60 requests per minute and the configured limit on each key is what applies, so the limit is a configuration conversation rather than a code change. Ask before a large run, not during one.
What happens if the batch exhausts my monthly credits halfway through?+
Requests start failing with quota_exceeded, and there is no overage billing — quotas are hard caps. That is why the worksheet computes credits before the run: for large batches, the arithmetic is the difference between a finished job and a half-finished one.

Evidence notes

Sources and limitations

Sources used

Limitations

  • Latency figures in the worksheet are placeholders for your own measurements. Nothing on this page states a Dokyumi processing time, because it varies with document size, page count and OCR mode.
  • Extraction is synchronous and one document per request. There is no bulk submission endpoint, and none is implied here.
  • Quotas are hard caps with no overage billing, so a batch larger than your remaining credits will stop rather than continue at extra cost.

Published and last reviewed September 1, 2026. Product behavior can change; the linked API, pricing, and security pages are the controlling public references.

Size the run before you start it

Measure latency on twenty documents, compute credits from your real page distribution, then scale. The free plan includes 25 credits a month and 2 schemas, with no card required, which is enough to test this on your own documents before deciding anything.