Gapless

Sync and Async Work

How to decide what runs inside a request, what streams, and what runs in background jobs, with the queue and retry patterns to make it reliable.

AI work is slow and uneven. A chat answer takes seconds. Indexing a 300-page PDF takes minutes. A nightly re-embedding of the whole corpus takes hours. If all of this runs the same way, the system is either slow for users or fragile in the background. This page explains how to split the work.

Three execution modes

ModeUse it whenExamplesUser experience
SynchronousThe work finishes in under about 1 second and the user waits for itLoad a chat, save feedback, list documentsNormal request and response
StreamingThe work takes seconds but produces output progressivelyChat answers, long summariesText appears token by token
Asynchronous jobThe work takes longer than a user will wait, or does not need the user at allIngest an upload, sync a connector, run an eval suite, batch summarizeThe user gets a job ID and sees progress or a notification

The rule of thumb: if a user could close the tab and still want the result, it is a job.

The design

Streaming

Streaming is what makes a 10 second answer feel fast. It is not optional for chat.

How it works. The server keeps the HTTP response open and writes small events as they are ready. The standard choice is server-sent events (SSE): one-way, text-based, works through most proxies, and reconnects automatically. WebSockets are only worth it if you need the client to send data mid-stream, such as live voice. See Streaming for the frontend side.

What to stream. Not just text. Use typed events so the frontend can render each part correctly:

event: token
data: {"text": "The travel budget "}

event: citation
data: {"n": 1, "title": "Travel Policy 2025", "url": "https://wiki/..."}

event: tool_call
data: {"name": "create_ticket", "status": "awaiting_confirmation"}

event: done
data: {"message_id": "msg_812", "usage": {"input": 3120, "output": 410}}

What breaks streaming. Proxies and load balancers that buffer responses. If tokens arrive in one big lump at the end, check for buffering in nginx (proxy_buffering off), in your CDN, and in any compression middleware. Also check idle timeouts: a model that thinks for 40 seconds before the first token will be cut off by a 30 second load balancer timeout. Send a comment line such as : keep-alive every 15 seconds.

Background jobs

When something becomes a job

Make it a job if any of these is true:

  • It can take more than about 10 seconds.
  • It processes more than one item, such as a folder of files.
  • It must survive a deploy or a crash and still finish.
  • It calls a rate-limited external API many times.

Job lifecycle

Store the job's state in the database, not only in the queue. The queue delivers work. The database is where the frontend and support staff look up what happened.

A minimal jobs table:

ColumnPurpose
idJob ID returned to the client
tenant_id, user_idWho owns it, for permissions and fair scheduling
typeFor example ingest_file, sync_connector, run_eval
statusqueued, running, succeeded, failed, dead, cancelled
attemptsRetry count
progressFor example {"done": 120, "total": 300}
errorLast error message, safe to show to support
idempotency_keyPrevents duplicate jobs for the same input
created_at, started_at, finished_atFor latency metrics and stuck-job alerts

Retries and idempotency

Jobs fail. Model APIs return 429 and 503. Parsers crash on odd files. Plan for it:

  • Retry with exponential backoff and jitter, for example 10 s, 30 s, 90 s, 270 s, then give up. Jitter stops a thousand failed jobs from retrying at the same second.
  • Retry only what can succeed later. A rate limit will clear. A corrupt PDF will not. Mark permanent failures as dead right away and show the reason.
  • Make every job idempotent. Running it twice must give the same result as running it once. For ingestion this means using a deterministic chunk ID, such as a hash of document ID plus chunk position, and upserting instead of inserting. Then a retry overwrites instead of duplicating.

Queues deliver messages at least once. Duplicates will happen. Idempotency is how you survive them.

Fair scheduling

One tenant uploading 50,000 files must not delay everyone else's single upload. Options, from simple to robust:

  1. Separate queues by priority: interactive uploads in a fast queue, bulk syncs in a slow queue.
  2. Per-tenant concurrency limits: each tenant can run at most N jobs at once.
  3. Weighted fair queuing: workers pick the next job from the tenant that has had the least recent share.

Start with option 1 and add option 2 when the first large customer arrives.

Long workflows

Some jobs are many steps: fetch files, parse, chunk, embed, index, notify. If step 4 fails, you do not want to redo steps 1 to 3. Two approaches:

  • Chain small jobs. Each step enqueues the next. Simple, and works well up to a few steps.
  • Use a durable workflow engine, such as Temporal or AWS Step Functions. The engine records each step's result and resumes from the failed step. Worth it when workflows have branches, waits, or human approval steps.

Telling the user

For jobs the user started, show progress where they started it. Two ways:

ApproachHowUse it when
PollingFrontend calls GET /jobs/:id every 2 to 5 secondsSimple, few concurrent jobs per user
PushServer sends events over SSE or WebSocket when status changesMany jobs, or you already have a live connection

Always handle the case where the user leaves and comes back. The job list must be loadable from the database, not only from live events.

Trade-offs

ChoiceBenefitCost
Do more work synchronouslySimpler code, immediate resultSlow requests, timeouts, poor experience under load
Move work to jobsResilient, scalable, fairNeeds a queue, a jobs table, progress UI, and idempotent code
Managed queue (SQS, Pub/Sub, Cloud Tasks)No servers to runVendor limits, such as message size and visibility timeouts
Self-hosted queue (Redis-based, RabbitMQ, Kafka)Full control, low latencyYou operate it, monitor it, and recover it
Workflow engineResume from failure, visible historyAnother system to learn and run

Checklist

  • Chat responses stream, and proxies do not buffer them.
  • Keep-alive events are sent during long model pauses.
  • Anything that can take more than about 10 seconds is a job with a database record.
  • Every job is idempotent and has a retry limit.
  • Permanent failures are not retried.
  • One tenant cannot starve others in the job queue.
  • Stuck jobs, running longer than expected, trigger an alert.

On this page