Queues and workflows for slow AI steps
In one line
When an AI step takes many seconds, fails randomly or runs in bulk, take it off the request path: accept the work, return 202, process it with queue workers whose concurrency is set by the provider's token limits, and use a durable workflow when the job has several steps, waits or human approvals.
What it is
Many AI tasks do not need a person watching tokens appear: extracting fields from an uploaded invoice, embedding a 300-page PDF, transcribing a call, scoring an order for fraud, summarising yesterday's tickets. These steps take seconds to minutes, hit provider rate limits and fail now and then with 429 or 5xx. Running them inside an HTTP request ties up connections, makes users wait and turns every provider hiccup into a user-facing error.
The async pattern: the API validates the request, stores the input (large files go to object storage such as S3), records a job row, publishes a message, and returns 202 Accepted with a job id. Workers consume messages, call the model through the AI gateway, store results and update status. The client learns the result by polling, by a webhook, by a push notification or over an SSE or WebSocket channel.
For a single step, a plain queue (SQS, RabbitMQ, a Kafka topic or a Postgres table with SELECT ... FOR UPDATE SKIP LOCKED) is enough. For multi-step jobs with retries per step, long waits, human review or compensation, a durable workflow engine such as Temporal or AWS Step Functions keeps the progress for you, so a crash resumes at the step that failed instead of starting over. The Messaging and events module covers queues and durable workflows in general; this lesson covers what is different when the slow step is a model.
Why interviewers ask
Designs like document ingestion, AI meeting notes, content moderation, eKYC with document extraction and AI code review all hinge on this. Interviewers want to see that you move slow AI work off the request path, size worker concurrency by tokens per minute rather than CPU, make retries safe with idempotency, avoid poison messages blocking the queue, choose between a queue and a workflow for good reasons, and use batch pricing when nobody is waiting.
How it works
- 1Accept and return fast: POST /jobs validates input, uploads the file to S3, inserts jobs(id, tenant_id, type, status='queued', input_ref, idempotency_key UNIQUE) and an outbox row in one transaction, and returns 202 with the job id. A repeated request with the same idempotency key returns the same job.
- 2Publish reliably: a relay or CDC reads the outbox and publishes to the queue, so a job is never saved without its message (or published without being saved).
- 3Pass references, not payloads: messages carry ids and S3 keys, not 20 MB documents; queues and workflow histories have size limits (around 1 MB per message by default for SQS and Kafka, and Temporal limits payload and history sizes).
- 4Size concurrency by the scarce resource: an I/O-bound worker that waits 10 s on a model uses almost no CPU, so CPU autoscaling is the wrong signal. If the provider quota is 2M tokens per minute and each job uses 8,000 tokens, the whole fleet can finish at most 250 jobs per minute; more workers just produce 429s faster. Enforce it with a shared token-bucket limiter in the gateway.
- 5Autoscale on backlog: scale workers on queue depth or consumer lag (for example with KEDA), capped by the token budget and, for Kafka, by the partition count of the topic.
- 6Separate priorities: interactive-adjacent jobs (a user waiting on 'processing your document') get their own queue and reserved quota; bulk backfills get a low-priority queue or a provider batch API at about half price.
- 7Retry safely: retry transient errors (429, 5xx, timeouts) with exponential backoff and jitter, respecting Retry-After; do not retry permanent errors (invalid input, content policy refusal). Set the queue's visibility timeout longer than the worst-case processing time, or extend it while working, so the job is not handed to a second worker mid-call.
- 8Make side effects idempotent: workers record processed job ids or use upserts keyed by job id, so a duplicate delivery does not create two summaries, two emails or two charges.
- 9Isolate poison messages: after N attempts move the job to a dead-letter queue with the error attached, mark it failed for the user, and provide a replay tool after the fix.
- 10Validate AI output before saving: parse JSON against a schema, check confidence; low-confidence results go to a human review queue instead of being accepted.
- 11Use a durable workflow for multi-step flows: for example extract text, then classify, then extract fields, then wait up to 48 hours for human review, then post to the accounting system. Each step is an activity with its own timeout and retry policy; waiting costs nothing; a crash resumes from the last completed step.
- 12Notify and observe: update status, notify the client, and track queue age (oldest message), job latency percentiles, retries, DLQ size, tokens and cost per job, with trace context carried in message headers.
Worked example · Processing 50,000 GST invoices uploaded at month end
- Load: on the last two days of the month, accounting firms upload 50,000 invoice PDFs, most within a 6-hour window: about 140 per minute on average, with bursts of 1,000 in a minute.
- Per invoice: OCR (2 s, CPU), then an LLM extraction call with about 6,000 input and 500 output tokens taking about 8 s.
- Provider quota: 3M tokens per minute. 6,500 tokens per invoice means at most about 460 invoices per minute. Average demand is about 140 per minute, so quota is enough, but bursts must queue.
- Workers: Little's Law for the LLM step: 460 per minute = 7.7 per second x 8 s = about 62 concurrent calls at full quota. 70 worker slots with a shared token limiter is enough; adding 300 workers would only cause 429s.
- Burst of 1,000 invoices: the queue absorbs it; draining at about 460 per minute while about 140 per minute keep arriving, the backlog clears in about 3 minutes. Users see 'processing' with a position, not errors.
- Failures: 2% of calls hit 429 or 5xx and succeed on retry with backoff; 0.3% return invalid JSON and are retried once with a repair prompt; 0.1% are unreadable scans and go to the DLQ and a manual review list.
- Human review: 6% of invoices have extraction confidence below 0.9. The workflow waits for a reviewer's signal for up to 48 hours, sending a reminder at 24 hours, then posts the approved data to the accounting system with an idempotency key.
- Cost: 50,000 x (6,000 x $1/M + 500 x $4/M) = 50,000 x $0.008 = $400 for the month-end run. Re-processing last year's archive is sent to the batch API at about half that rate.
Picture it
It is like a passport office. You hand in the application at the counter, get a token number and leave; officers in the back work through the pile at the pace the verification department allows, send doubtful cases to a senior officer, and you get an SMS when it is ready. Nobody stands at the counter for three days.
Reference code
from datetime import timedelta
from temporalio import workflow
from temporalio.common import RetryPolicy
with workflow.unsafe.imports_passed_through():
from activities import extract_text, extract_fields, post_to_ledger
LLM_RETRY = RetryPolicy(
initial_interval=timedelta(seconds=2),
backoff_coefficient=2.0,
maximum_interval=timedelta(minutes=2),
maximum_attempts=6,
non_retryable_error_types=["InvalidDocument"],
)
@workflow.defn
class InvoiceWorkflow:
def __init__(self) -> None:
self.review = None # set by a signal from the reviewer UI
@workflow.signal
def reviewed(self, fields: dict) -> None:
self.review = fields
@workflow.run
async def run(self, invoice_id: str, s3_key: str) -> str:
text_key = await workflow.execute_activity(
extract_text, s3_key, start_to_close_timeout=timedelta(minutes=5))
result = await workflow.execute_activity(
extract_fields, text_key, start_to_close_timeout=timedelta(minutes=2),
retry_policy=LLM_RETRY)
fields = result["fields"]
if result["confidence"] < 0.9:
await workflow.wait_condition(lambda: self.review is not None,
timeout=timedelta(hours=48))
fields = self.review
# invoice_id doubles as the idempotency key in the ledger API
return await workflow.execute_activity(
post_to_ledger, args=[invoice_id, fields],
start_to_close_timeout=timedelta(seconds=30))Key patterns
202 Accepted + job resource
When: Work takes longer than a few seconds and nobody needs token streaming
How: Return a job id immediately; expose GET /jobs/{id}; notify by webhook, push or SSE when done.
Token-budgeted worker pool
When: Workers call rate-limited model APIs
How: Cap total in-flight calls and tokens per minute with a shared limiter; autoscale on backlog up to that cap, never beyond.
Priority lanes
When: Mixed urgent and bulk AI jobs
How: Separate queues with reserved quota for urgent jobs; send bulk jobs to a low-priority queue or a batch API.
Durable workflow for multi-step AI
When: Several model calls, tool calls, waits or human approvals
How: Each step is an activity with its own retry policy and timeout; workflow state survives crashes; store large data in S3 and pass references.
Real-world use
Temporal (from Uber's Cadence)
Temporal grew out of Cadence, a workflow engine built at Uber. It is widely used for long-running, multi-step processes, and AI teams use it to orchestrate LLM steps, tool calls and human approvals with per-step retries.
KEDA for queue-based autoscaling
KEDA scales Kubernetes workers on queue length or Kafka consumer lag, including down to zero, which suits bursty AI workloads better than CPU-based autoscaling for I/O-bound workers.
Provider batch APIs
OpenAI and Anthropic both offer batch endpoints that accept large files of requests and return results within 24 hours at about half the normal price, a natural sink for low-priority queues.
Amazon SQS dead-letter queues
SQS moves a message to a dead-letter queue after a configured number of receives and supports redriving messages back after a fix, a standard way to stop poison messages blocking AI workers.
In a project
A Chennai health-tech company transcribes and summarises doctor consultations. Each 15-minute recording goes through speech-to-text, then an LLM summary, then a doctor's approval in the app. The current synchronous API times out for long recordings and sometimes creates duplicate summaries when clients retry.
- Change upload to return 202 with a consultation job id; store audio in S3 in the India region and pass only the S3 key.
- Model the flow as a Temporal workflow: transcribe activity (timeout 10 min), summarise activity (timeout 2 min, retries with backoff on 429/5xx), then wait for a 'doctor approved' signal for up to 72 hours with a reminder notification at 24 hours.
- Use the job id as the idempotency key for the summary row and for the final EHR write, so client retries and activity retries do not create duplicates.
- Set worker concurrency from the speech and LLM providers' quotas, with a shared limiter in the AI gateway; scale workers on task-queue backlog.
- Validate the summary JSON (sections: complaints, diagnosis, prescriptions) against a schema; flag low-confidence medication names for the doctor to check.
- Push a notification and an in-app update when the summary is ready; track time from upload to ready (p95) and the DLQ size on a dashboard.
Impact: Timeouts disappear, duplicate summaries go to zero, p95 time to summary is about 3 minutes even during morning OPD peaks, and a provider outage only delays summaries instead of losing recordings.
Trade-offs
Use it when
- AI steps take more than a few seconds, or longer than HTTP and proxy timeouts allow
- Bulk or scheduled processing: ingestion, re-embedding, nightly summaries, backfills
- Work must survive provider outages and rate limits without failing user requests
- Multi-step flows with human review or long waits (use a durable workflow)
Avoid it when
- A person is waiting for generated text in a chat: stream it instead
- The step is fast (a few hundred milliseconds) and the result is needed in the same response
- A durable workflow engine for a single short step that a plain queue handles; it adds operational weight
- Users need strict ordering across all jobs; queues usually give ordering only per key or partition
Common mistakes
- Calling the LLM inside the HTTP request for long jobs, so proxies time out and clients retry, creating duplicates.
- Autoscaling AI workers on CPU, which stays low while they wait on the model.
- Running more concurrent workers than the provider's token quota allows, generating 429 storms instead of throughput.
- Visibility timeout shorter than the model call, so a second worker picks up the same job and both write results.
- Retrying permanent failures (invalid documents, policy refusals) forever, or letting a poison message block a partition.
- Putting whole documents or transcripts in queue messages or workflow history instead of S3 references.
- No idempotency on the final write, so retries post the same invoice to the ledger twice.
- No visibility for users: a job stuck in a queue with no status, position or failure message.
Interview questions
Quick revision
- Slow, bulk or failure-prone AI work: 202 + job id, not a long HTTP request.
- Store inputs in S3; messages carry ids and keys (queue messages are limited to about 1 MB).
- Outbox or CDC so a saved job always gets published.
- Throughput cap = provider tokens per minute / tokens per job; size workers to that, not CPU.
- Autoscale on queue depth or lag (KEDA), capped by quota and partitions.
- Retry 429/5xx with backoff, jitter and Retry-After; never retry permanent errors.
- Visibility timeout > worst-case processing time.
- Idempotent side effects keyed by job id; DLQ after N attempts plus replay tool.
- Priority lanes: reserved quota for urgent jobs, batch API for bulk at about half price.
- Durable workflow when there are many steps, waits or human approvals.
- Watch queue age, job latency p95, DLQ size, tokens and cost per job.
Check yourself
1. Workers call an LLM with a quota of 1.2M tokens per minute. Each job uses 6,000 tokens. What is the maximum sustainable throughput?
2. A job sometimes gets processed twice, producing two summaries. The model call takes up to 90 seconds and the SQS visibility timeout is 30 seconds. What is the root cause?
3. Which signal is best for autoscaling I/O-bound workers that mostly wait on model responses?
4. An onboarding flow runs document extraction with an LLM, waits up to 3 days for a human reviewer, then provisions an account. What fits best?
See it in a full design
These walkthroughs use this lesson.
Saved on this device. Create a free account to keep it on every device.
Practise this with Sam
A real mock interview, out loud, with an honest scorecard. Free to start.