Asyncbackground jobsworkersqueueretrybackoff

Background Jobs and Workers

Move work that does not have to finish inside the request — image processing, email, reports, AI generation, transcoding — onto a queue consumed by workers, and accept that a job which can be retried will eventually run twice.

▶ InteractiveInterview questionDebug it
Progress
What problem does this solve?

A request that does 40 ms of database work and then 6 s of video transcoding holds a connection, a thread and the user for 6 s, and fails entirely if the transcoder hiccups. A job queue lets the API answer in 40 ms with a job id and moves the slow, failure-prone work to workers that can retry, throttle and scale independently.

Why work leaves the request path

The request path is the most expensive place to do anything: the user is waiting, a connection is held, the load balancer has a timeout ticking, and any failure surfaces as an error the user sees. Work that the user does not need to *see* finish — sending the confirmation email, resizing the uploaded image, rendering the monthly report, calling a model that takes 20 s, transcoding a video — belongs elsewhere. The API writes a job to a queue and returns 202 Accepted with a job id; a fleet of workers consumes the queue and does the work at its own pace.

This is the same decision as Request/Response vs Event-Driven, applied to work rather than to calls. The gain is decoupling in three dimensions: latency (the API responds in milliseconds), failure (a transcoder outage becomes a growing backlog, not a stream of 500s), and capacity (workers scale on queue depth, the API scales on request rate). The cost is that the job now has a lifecycle you must manage, and that the user has to be told what happened later.

API → queue → worker
POST /videosjob: queuedenqueuedequeuetranscodejob: doneClientAPIJob queueWorker ×NDatabaseEmail / S3 / model API
ClientGateway / LBServiceWorkerDatabaseCacheQueue / LogObject storageCDNExternal system

The job lifecycle

A job is a row (or a message) with a state. It is queued when the API writes it, running when a worker takes it, succeeded or failed when the worker finishes, and — the state people forget — retrying between a failure and the next attempt. The worker takes a job with a lease (a visibility timeout in SQS terms, a locked_until column in a database queue): if the worker dies mid-job the lease expires and another worker picks the job up. That is what makes the system survive worker crashes, and it is also why every job runs at least once, never exactly once — a worker that finished the work but died before acknowledging leaves a job that will run again.

So every handler must be safe to run twice. Sending an email twice is annoying; charging a card twice is an incident. Handlers achieve this by keying side effects on the job id (the email provider gets idempotency_key = job.id), by making the write an upsert, or by checking a processed_jobs table inside the same transaction as the work — the patterns are the subject of the next lesson. Timeouts bound a single attempt: a transcoding job that normally takes 6 s should be killed at 60 s, marked failed, and retried, rather than holding a worker forever.

  • Retries with backoff: 1 s, 4 s, 16 s, 64 s with jitter — never a tight loop. A dependency that is down for 2 min should see a handful of attempts, not 10,000.
  • Max attempts then a dead-letter state that keeps the payload. A job that fails five times is a bug or a poison payload; retrying forever hides it and burns capacity.
  • Attempt counter and last error stored on the job so an operator can see *why* it is failing without reading logs.
States and transitions of one job
queued ──lease──▶ running ──ok──▶ succeeded
                    │
                    ├──error, attempts < max──▶ retrying ──backoff elapsed──▶ queued
                    │
                    └──error, attempts = max──▶ failed (dead-letter: keep the payload, alert)

Concurrency, priorities and schedules

Workers pull with a bounded concurrency — 4 transcodes per 8-core box, 50 emails per worker because the provider allows 100 req/s across the fleet. Concurrency limits are the worker-side half of Backpressure: without them a burst of 10,000 jobs turns into 10,000 simultaneous calls to a provider that will rate-limit or fall over. Limits are often per *resource* rather than per worker: a global semaphore in Redis (INCR with a cap, see Redis: Data Structures, Not a Cache) caps calls to one external API across all workers.

Priorities exist because a queue is FIFO and not all jobs are equal: a password-reset email must not wait behind 50,000 newsletter sends. The cheap solution is separate queues per priority class, each with its own workers, so a flood in one cannot starve another; a single Priority Queue is the in-memory version of the same idea. Scheduled jobs — run at 03:00, run every 5 min — are a scheduler that *enqueues* at the right time, not a worker that sleeps; that keeps the execution path identical and makes a missed schedule visible as an empty queue rather than a silent no-op. Jobs with dependencies (thumbnail after transcode, index after upload) form a small DAG (Directed Acyclic Graph) and run in Topological Sort order; most systems model this as a job that enqueues its successors on success.

Telling the user, and scaling the workers

The user submitted a video and got a job id. Now what? Two options. Polling: the client calls GET /jobs/{id} every few seconds and reads the state — simple, works through any proxy, and costs one cheap read per poll; back off the interval as the job ages. Push: the server notifies the client over a WebSocket, Server-Sent Events, or a webhook when the state changes — better latency and no polling load, at the cost of a stateful connection or a webhook delivery problem of its own (see API Architecture: REST, GraphQL, RPC, gRPC, WebSockets, Webhooks). Most products poll first and add push when polling load or perceived latency becomes a measured problem. Either way, the job row is the source of truth; the notification is a hint to go read it.

Workers scale on queue depth and oldest-message age, not on CPU. If the queue holds 20,000 jobs and the oldest is 15 min old, add workers; if the queue is empty, remove them. Autoscaling on that signal is the point of the whole design — the API fleet never needs to know how many workers exist. The limit is downstream: adding workers does nothing if they all wait on the same rate-limited provider or the same database, and that is where the queue starts hiding a capacity problem rather than absorbing a burst.

Key points

  • Anything the user does not need to see finish leaves the request path: API returns 202 + job id, workers do the work.
  • Leases and retries mean every job runs at least once; every handler must be safe to run twice.
  • Backoff with jitter, a max-attempt count, and a dead-letter state with the payload kept — never retry forever.
  • Separate queues per priority class so a newsletter flood cannot starve a password reset; a scheduler enqueues, it does not execute.
  • Scale workers on queue depth and oldest-message age; the ceiling is the slowest downstream dependency, not worker count.

API → queue → worker, with retries

API → queue → worker, with retries
The API answers 202 immediately; workers do the slow part later. Change the job type, the load and the retry policy, then advance time.
enqueuepickwrite resultcallack / nackAPIQueueWorkersDatabaseExternal API
Job type
Retry policy
queued38
running4 · of 4 workers
done38
retrying (in backoff)0
dead-lettered0
p50 to complete
3000 ms
p95 to complete
6050 ms
GET /jobs/0 → status
done
workers needed
7 (8/s × 800 ms)
Lifecycle of job 0 (the one the client polls)
0.10senqueued by API → 202 Accepted {jobId: 0, status: "pending"}
0.10spicked by worker (attempt 1) — waited 0 ms in the queue
0.90sfailed (attempt 1) → nack; backoff 1 s
1.95spicked by worker (attempt 2) — waited 1850 ms in the queue
2.75ssucceeded (attempt 2) → result written, message acked
Sustained 8 jobs/s × 800 ms means 6.4 workers are busy on average — you have 4. The queue only hides this for a while: depth grows by about 3.0 jobs every second and the p95 time-to-complete becomes queueing time, not work. Scale workers on queue depth (or age of the oldest message), not on CPU. Whatever the policy, handlers must be idempotent: a worker can die after the side effect (email sent, provider charged) and before the ack, and the message will be delivered again.
t = 10 s · 80 jobs

How data moves through it

One request or event, hop by hop.

  1. 1Client → API: POST /videos with the upload reference.
  2. 2API → Database: insert jobs(id, type, payload, state=queued, attempts=0) in the same transaction as the domain write.
  3. 3API → Queue: enqueue the job id; API → Client: 202 Accepted { jobId }.
  4. 4Queue → Worker: worker takes the job with a lease; sets state=running.
  5. 5Worker → External: transcode / send / call; keyed by job id so a repeat is harmless.
  6. 6Worker → Database: state=succeeded, acknowledge the message; Client polls GET /jobs/{id} or receives a push.

When to use — and when not

Use it when
  • Work that takes longer than the user should wait: transcoding, report generation, model inference, bulk imports.
  • Side effects on flaky external systems (email, SMS, third-party APIs) that need retries the request path cannot afford.
  • Bursty work where the arrival rate is far above the sustainable processing rate and a delay of seconds to minutes is acceptable.
Avoid it when
  • The caller needs the result to continue — a price check before showing the cart. A queue adds latency and a status endpoint for nothing.
  • The work is 20 ms and never fails. A queue, workers and a job table are more moving parts than a function call.
  • Strict ordering across jobs matters and you would have to serialise the workers anyway; use a partitioned log (Kafka-Style Logs: Topics, Partitions, Offsets) or do it inline.

Tradeoffs

Complexity
low → high
Ops cost
low → high
Latency
low → high
Consistency
weak → strong
Scalability
poor → strong

The API gets faster and the system gets more resilient; in exchange you own a job lifecycle, at-least-once semantics, and a status story for the user.

How it fails

  • A handler that is not idempotent runs twice after a worker crash or a lease expiry — the classic double email or double charge.
  • Retries without backoff or a cap turn a 2-minute provider outage into a self-inflicted flood that keeps the provider down.
  • One queue for everything: a bulk job with 500,000 items delays every urgent job behind it for an hour.
  • Workers scaled on CPU instead of queue depth: CPU sits at 20% because workers are waiting on I/O while the backlog grows for hours.
  • Job state written after the side effect in a separate transaction: the email is sent, the state write fails, the job retries and sends again.

How it scales

  • Add workers on queue depth and oldest-message age; workers are stateless so this is a container count.
  • Split queues by priority or by job type so one class of work scales independently and cannot starve another.
  • The ceiling moves downstream: a rate-limited provider or a single database becomes the bottleneck, and then the queue only buys time — see Backpressure.
  • For very high rates, a partitioned log gives per-key ordering and replay that a simple queue lacks, at the cost of more operational machinery.

How it interacts with databases, queues, caches, APIs and external systems

  • Queue: SQS-style queue with visibility timeout, Redis lists/streams, or a jobs table polled with SELECT … FOR UPDATE SKIP LOCKED — the last is the right start for one database and modest volume.
  • Database: the job row is the source of truth for state; write it in the same transaction as the domain change so a job is never enqueued for an order that was rolled back.
  • Cache/Redis: global concurrency semaphores and rate counters shared by all workers.
  • External APIs: every call carries an idempotency key derived from the job id; provider rate limits set the worker concurrency.
  • API: a GET /jobs/{id} status endpoint; optionally a webhook or WebSocket for push.
Don't delegate understanding
The manifesto →

A worker that dies after the side effect and before the ack is the whole reason handlers must be idempotent.