Notes
The signals
Listen for work that takes more than a few seconds: transcoding, PDF exports, code judging, CI builds, bulk imports, heavy model inference. Load balancers and proxies typically cut idle connections after about sixty seconds, and phones drop connections far sooner. When a task can outlive the request that started it, this is your pattern.
- video or audio transcoding
- report, export, invoice PDFs
- code judging, CI builds
- ML inference on big inputs
- bulk email, data imports
- takes 5 s to hours
- load balancer idle timeout:
- often 60 s
- users can wait, but not
- with a spinner
The naive design
The naive design does the work inside the request handler. At sixty seconds the load balancer returns a timeout, though the server may still be working. The user hits retry, and now the job runs twice. A deploy or crash loses the job silently. And heavy jobs eat the CPU that fast API requests need, so the whole site slows down. You cannot scale the two kinds of work separately.
- request times out at 60 s,
- the work may continue
- user retries → the job runs
- twice
- deploy or crash → job lost,
- nobody knows
- heavy jobs starve the fast
- API requests on the box
- cannot scale the two apart
Move 1: async job API
Move one: change the API. The client posts the job, the API records it with status queued and returns two hundred two Accepted with a job ID, in milliseconds. A separate endpoint reports status and progress, and another cancels. Send an idempotency key on submit, so a retried submit returns the same job instead of creating a second one.
POST /exports
Idempotency-Key: abc
→ 202 Accepted
{ job_id: 77, status: queued }
GET /jobs/77
→ { status: running,
progress: 40 }
DELETE /jobs/77 (cancel) Move 2: queue + workers
Move two: a queue and a worker pool. The API writes the job row and puts the job ID on a queue. Workers on a separate fleet, possibly with big CPUs or GPUs, pull jobs, run them, write results and update the status. Now the API stays small and fast, and you scale workers independently. Use an outbox so the job row and the queue message cannot disagree. Concepts episode twenty-eight covers queues.
- API: write job row, enqueue
- the id (or use an outbox)
- workers: pull, run, write
- results, update status
- separate fleet: big CPU or GPU
- boxes, scaled on their own
- API stays fast and small
- see the Job scheduler block
- for timed jobs
Leases
The key mechanism is the lease. When a worker pulls a job, the queue hides it for, say, five minutes rather than deleting it. The worker acknowledges when done, and only then is it removed. If the worker crashes, the lease expires and the job reappears for someone else. That gives at-least-once execution, so jobs must be idempotent: write output to a fixed key, and use upserts.
- pull = lease, the job is hidden
- (SQS visibility timeout)
- worker acks when finished
- → deleted
- worker dies → lease expires
- → job reappears for another
- = at-least-once execution
- so jobs must be idempotent:
- same output key, upserts
Heartbeats
What about a job that runs for an hour? The worker sends a heartbeat every thirty seconds that extends its lease and records progress. If heartbeats stop, the job is presumed dead and re-queued. For big jobs, checkpoint: if segments one to forty of a video are already transcoded and saved, a restart begins at forty-one. Even better, split the job into many small ones, one per segment, and run them in parallel.
- heartbeat every 30 s:
- extend lease, save progress
- no heartbeat for 2 min
- → presume dead, re-queue
- checkpoint big jobs:
- transcoded segments 1-40
- saved → restart at 41
- or split into many small
- jobs (one per segment)
Retries
Retries. Transient failures, like a timeout or a busy dependency, get retried with exponential backoff and jitter. Permanent failures, like a corrupt input file, should fail fast and tell the user. After about five attempts, move the job to a dead-letter queue so one poison job cannot loop forever and block a worker. Alert when the dead-letter queue grows.
attempt 1 fails → retry in 10 s attempt 2 fails → retry in 40 s attempt 3 fails → retry in 160 s attempt 5 fails → dead-letter transient (timeout, 503): retry with jitter permanent (bad input): fail fast, tell the user alert on dead-letter depth
Telling the user
How does the user find out? The simplest answer is polling the status endpoint every few seconds, which is perfectly fine for most products. For a snappier experience, push the update over SSE or a WebSocket, see the real-time updates pattern. API customers get a signed, retried webhook. For hour-long jobs, send an email. The result itself is usually a file in object storage behind a presigned link.
- poll GET /jobs/77 every 2 to
- 5 s (simple, fine for most)
- push over SSE or WebSocket
- (see Real-time updates)
- webhook for API customers:
- POST to their URL, signed,
- retried
- email for jobs of hours
- result: presigned link to S3
Scaling workers
How many workers do you need? Use Little's law: concurrent workers equals jobs arriving per second times seconds per job. Fifty jobs a second at thirty seconds each means fifteen hundred jobs running at once. Autoscale on queue depth, or better, on the age of the oldest waiting job, which maps directly to what users feel. Keep separate queues for quick and slow jobs, so one long job type cannot block the short ones.
Little’s law:
busy workers = arrivals/s
× seconds per job
50 jobs/s × 30 s = 1,500
concurrent workers
autoscale on queue depth or
age of the oldest job
separate queues: fast vs slow,
paid vs free Fairness
Fairness. If one customer submits a hundred thousand jobs into a single first-in, first-out queue, everyone behind them waits hours. Give tenants their own queues or quotas, schedule across them fairly, cap how many jobs one tenant can run at once, and keep a priority lane so interactive jobs jump ahead of overnight batches. Rate limit submissions at the API.
- one customer submits 100k
- jobs: everyone else waits
- per-tenant queues or quotas
- weighted fair scheduling
- priority lanes: interactive
- above batch
- cap concurrency per tenant
- rate limit submissions
- (see the Rate limiter block)
Cancellation
Give every job a maximum runtime, for example twice its normal ninety-ninth percentile, and kill it beyond that. Cancellation is cooperative: the API marks the job as cancelling, and the worker checks that flag at each checkpoint and stops at the next safe point, cleaning up partial outputs. Always record the final status and a reason, so support can answer what happened to my export.
- every job has a max runtime
- (kill at 2x the p99)
- cancel: set status=cancelling
- worker checks the flag at each
- checkpoint and stops
- already running on a GPU? stop
- at the next safe point
- clean up partial outputs
- record final status + reason
Trade-offs
The trade-offs. Going async buys reliability and independent scaling, at the cost of more moving parts and a user experience that has to show progress honestly. At-least-once delivery forces idempotent jobs. Polling is simple but wasteful; push is instant but stateful. Splitting into small jobs adds parallelism and resumability, but also scheduling overhead. Monitor queue depth, oldest job age, failure rate and dead letters.
- async: robust and scalable,
- but more moving parts
- and a harder UX
- at-least-once: jobs must be
- idempotent
- polling: simple, wasteful
- push: instant, stateful
- many small jobs: parallel and
- resumable, more overhead
- monitor: depth, age, failures
Where it shows up
Where you will use it. The online code judge runs submissions in sandboxed workers and reports a verdict. YouTube's upload pipeline transcodes in the background. The job scheduler adds timing on top of this pattern. CI pipelines run builds as jobs. The notification system fans out bulk sends, and a web crawler is a giant queue of fetch jobs.
- Online code judge: run code
- in sandboxed workers
- YouTube: transcode pipeline
- Job scheduler: timed and
- recurring jobs
- CI/CD pipeline: builds
- Notification system: bulk
- sends
- Web crawler: fetch queue
Say it like this
The phrasing. Submitting returns two hundred two with a job ID. The API stores the job and enqueues it. Workers lease jobs, send heartbeats and checkpoint, and jobs are idempotent because delivery is at least once. Failures back off and land in a dead-letter queue. The client polls or gets a push when it is done, and workers autoscale on the age of the oldest job.
- "POST returns 202 with a job
- id. The API writes the job
- and enqueues it. Workers
- lease jobs, heartbeat, and
- checkpoint; failed jobs back
- off and land in a DLQ. The
- client polls or gets a push.
- Workers autoscale on the
- oldest job age."