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.
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.
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.
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
How data moves through it
One request or event, hop by hop.
- 1Client → API:
POST /videoswith the upload reference. - 2API → Database: insert
jobs(id, type, payload, state=queued, attempts=0)in the same transaction as the domain write. - 3API → Queue: enqueue the job id; API → Client:
202 Accepted { jobId }. - 4Queue → Worker: worker takes the job with a lease; sets
state=running. - 5Worker → External: transcode / send / call; keyed by job id so a repeat is harmless.
- 6Worker → Database:
state=succeeded, acknowledge the message; Client pollsGET /jobs/{id}or receives a push.
When to use — and when not
- 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.
- 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
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
jobstable polled withSELECT … 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.
A worker that dies after the side effect and before the ack is the whole reason handlers must be idempotent.