Your function times out before the document finishes

Extraction takes 3 seconds on a good page and 17 on a bad one, so long documents outlive the request. Why bigger timeouts and retries make it worse, and the job pattern that fixes it.

5 min read

You wired document extraction into an API route. It works on a one page receipt. Then someone uploads a twelve page scanned contract, the request hangs, and your logs show a 504 with no error from the model at all. The extraction probably succeeded. Your function just was not around to hear the answer.

This is not a bug you can tune away with a bigger timeout, and this post explains why, then shows the pattern that actually fixes it.

Why extraction takes longer than a request wants to live

Document extraction is not a fast operation, and the tail is much worse than the median. Measured across 1,651 real pages on our default engine:

Time
Median page3.4 seconds
95th percentile page17.3 seconds

A single page usually lands in a few seconds. But one page in twenty takes five times longer, and a multi page document multiplies that. A twenty page document with a bad tail is minutes, not seconds.

Now put that against what your platform allows a request to run. Serverless limits have grown a lot, and on most platforms a paid tier now buys you several minutes rather than the ten seconds of a few years ago. That helps, and it does not solve the problem, because the shape of the risk has not changed: your worst documents are the ones that time out, and your worst documents are the ones you most need to process. You are one unusually dense scan away from a failure that only happens in production.

The three fixes that do not work

Raising the timeout. Moves the cliff, does not remove it. You still fail on the documents at the tail, and now you also hold an expensive execution context open doing nothing but waiting.

Retrying on timeout. This is the expensive mistake. Your function gave up, but the extraction did not: it kept running and finished. Your retry starts a second one. You now pay twice for the same document, and if you retry three times you pay three times for work you already had.

Streaming the response. Streaming keeps the connection warm and buys time on some platforms, but it ties the result to a connection the user's browser can drop, and there is nothing to reconnect to. It also does nothing when the caller is another service rather than a browser.

The pattern under all three is the same: they treat a long job as a slow request. It is not. It is a job.

The pattern that works

Accept the work, hand back an id immediately, do the work elsewhere, and tell the caller when it is done.

  1. Accept and acknowledge. Your endpoint validates the input, queues the work, and returns an id in well under a second. The request is finished long before any timeout matters.
  2. Process out of band. A queue consumer does the extraction with a time budget measured in minutes, not in whatever your HTTP layer tolerates.
  3. Deliver the result. Either the caller polls a status endpoint, or you post the result to a webhook when the job reaches a terminal state.
  4. Make retries safe. Retrying should be a property of the queue, with backoff, and it should retry the job, not create a second one. That is the difference between one charge and three.

This is not a novel design. It is how every mature processing API works, and the reason is exactly the one above.

What this looks like on Veralens

Jobs are the API. There is no synchronous extraction endpoint to time out, because we did not want to hand you the problem:

bash
curl -X POST https://api.veralens.ai/v1/jobs/create \
  -H "Authorization: Bearer vl_your_secret_key" \
  -H "Content-Type: application/json" \
  -d '{
    "sources": [{ "type": "document", "data": "data:application/pdf;base64,JVBERi0..." }],
    "target": "markdown",
    "webhook_url": "https://your-app.example.com/hooks/veralens"
  }'

That returns 201 with the full job object in pending, immediately. Your route is done. When the job finishes, the result arrives at your webhook, or you fetch it whenever you like:

bash
curl https://api.veralens.ai/v1/jobs/V1StGXR8_Z5jdHi6B/get \
  -H "Authorization: Bearer vl_your_secret_key"

Three details that matter for the failure cases above:

  • Retries have backoff and never duplicate the work. A transient failure is retried inside the job. You are not creating a second job and not paying twice.
  • A failed job refunds its hold in full. Cost is held when the job is created and settles to real usage on completion, so a failure costs nothing.
  • Failures are typed. A document the model refuses to transcribe, an output that runs past the model's limit, and an engine that is temporarily unavailable are different error codes, not one generic failure. You can decide which are worth retrying and which are worth surfacing to the user.

If you are keeping your own pipeline

The advice holds even if you never touch our API. Move extraction behind a queue, return an id, deliver by webhook or polling, retry the job rather than the request, and record cost per job so a runaway document is visible rather than mysterious. The failure you are trying to avoid is not the timeout itself. It is paying three times for a document you already successfully extracted.

If you would rather not build that, the jobs API is that pattern, and the quickstart gets you to a first extraction in a few minutes.

Try it on your own documents

Add funds from $5, create a key, and send your first document. Every job reports exactly what it cost.

Start extracting

Keep reading