Asynchronous Processing
Accept a request fast, then do the heavy work later in a background worker. Watch jobs flow through a queue and a worker pool, see what happens when a worker crashes mid-job, and learn why every handler must be idempotent.
🎯 What you'll learn
- Deferring work across system boundaries: queue + worker pool
- Fire-and-forget vs tracked jobs vs async with notification
- At-least-once delivery and idempotent handlers
- Ordering, concurrency limits and growing backlogs
📚 Before you start
1What "Asynchronous Processing" Means at the System Level
This topic is about a specific pattern built on top of asynchronous communication (previous topic): work that is deliberately deferred, queued, and executed later — separate from the request that triggered it. The classic shape:
1. A request arrives (HTTP call, event, upload, etc.)
2. The service does the minimum work needed to accept it, then responds immediately
3. The actual work is handed off — to a background job, a queue, a worker pool
4. A worker picks up the deferred work and executes it, independently of the original callerThis is different from the JavaScript-level concept of an async function or a Promise (covered fully in languages/javascript/27-event_loop and languages/javascript/29-promises) — those are the mechanism a single process uses to avoid blocking on I/O. This topic is about deferring work across a system boundary, often to an entirely different process, possibly minutes or hours later.
2Why Defer Work At All
- Fast responses. A user uploading a video shouldn't wait for transcoding to finish before seeing "upload received."
- Absorbing spikes. A flash sale generates 50,000 order confirmations in a minute; sending them all synchronously would overwhelm the email provider and the checkout path with it.
- Resource isolation. A CPU-heavy report generation job shouldn't run on the same request thread pool that serves login requests.
- Resilience. If the email provider is down for ten minutes, queued jobs simply wait — they don't turn into failed checkouts.
3The Building Blocks: Job Queue + Worker Pool
// producer side: enqueue work instead of doing it inline
async function handleUpload(fileId: string) {
await storage.save(fileId);
await jobQueue.enqueue("transcode-video", { fileId });
return { status: "accepted", fileId };
}
// worker side: a pool of workers pulls jobs and executes them independently
async function runWorker(jobQueue: JobQueue) {
while (true) {
const job = await jobQueue.dequeue(); // blocks until work is available
try {
await transcodeVideo(job.payload.fileId);
await jobQueue.ack(job.id);
} catch (err) {
await jobQueue.nack(job.id); // re-queue or dead-letter, see Dead Letter Queues
}
}
}A worker pool runs N workers concurrently pulling from the same queue, so throughput scales by adding workers (bounded by the downstream resource — a database, an external API — not by the queue itself).
4Fire-and-Forget vs Tracked Async Work
Not all deferred work is equal — it matters whether anyone needs to know the outcome:
| Pattern | Caller cares about the result? | Example |
|---|---|---|
| Fire-and-forget | No — best-effort only | Logging an analytics event |
| Tracked async job | Yes, eventually — via polling or a callback | Video transcoding status shown in a UI |
| Async with notification | Yes, pushed back to the caller | Email sent when a report finishes generating |
A tracked job needs a job status record (pending → running → completed/failed) that the caller can poll (GET /jobs/{id}) or be notified about (webhook, WebSocket, another queue message) — the queue itself usually doesn't retain enough history for this once a job is dequeued.
5Idempotency: The Non-Negotiable Requirement
Because queues generally offer at-least-once delivery (a job can be delivered more than once — see the Message Brokers group's Delivery Guarantees topic), every job handler must be idempotent: running it twice with the same input must produce the same end state as running it once.
// NOT idempotent — running twice charges the customer twice
async function chargeCustomer(orderId: string, amount: number) {
await paymentGateway.charge(amount); // no way to detect "already charged"
}
// idempotent — a unique key lets the downstream system detect duplicates
async function chargeCustomerIdempotent(orderId: string, amount: number) {
await paymentGateway.charge(amount, { idempotencyKey: `charge-${orderId}` });
// the gateway itself recognizes a repeated idempotencyKey and returns the
// ORIGINAL result instead of charging again
}Idempotency has to be designed in at the handler (or the downstream API), not assumed away — "the queue probably won't redeliver" is not a guarantee any real broker makes.
Handler without dedup
async function sendReceipt(job) {
await email.send(job.userEmail, receipt(job.orderId));
}
// redelivered after a crash → customer gets 2 receiptsIdempotent handler
async function sendReceipt(job) {
const first = await db.insertIgnore('sent_receipts', job.orderId);
if (!first) return; // already handled
await email.send(job.userEmail, receipt(job.orderId),
{ idempotencyKey: `receipt-${job.orderId}` });
}6Ordering and Concurrency
Workers processing jobs concurrently means jobs for the same logical entity can run out of order or in parallel unless something prevents it:
// two jobs for the SAME order running concurrently on different workers
// can race: job A sets status="shipped", job B (an older event) then
// overwrites it back to status="paid"Common fixes: partition work so all jobs for one entity always go to the same worker (this is exactly what Kafka's partitioning does — see the Message Brokers group), include a version/timestamp check before applying an update ("only apply this if it's newer than the current state"), or use a per-entity lock while processing.
7Backpressure and Scaling the Worker Pool
If jobs arrive faster than workers can process them, the queue backs up. This is the subject of the next topic in full, but the shape here: either scale workers (add more, bounded by the downstream resource's capacity) or apply backpressure upstream (slow down or reject new work) — silently letting an unbounded queue grow is the failure mode that the next topic addresses directly.
If the database allows 20 connections, 200 workers don't make jobs faster; they cause pool exhaustion and timeouts. Concurrency = what the slowest shared dependency can sustain.
Under the HoodHow a Worker Pool Actually Schedules Concurrent Jobs▾
Concurrency is bounded, not unlimited
A worker pool doesn't spawn one worker per job — it runs a fixed number of concurrent workers (or a fixed concurrency limit within one process), each looping: dequeue → process → ack → dequeue again.
async function runPool(jobQueue: JobQueue, concurrency: number) {
const workers = Array.from({ length: concurrency }, () => runWorker(jobQueue));
await Promise.all(workers); // all N workers run concurrently, forever
}Within a single Node.js process, "concurrent" workers pulling from an in-memory or network-backed queue are not running in true parallel (Node is single-threaded for JS execution) — they interleave on the event loop, exactly as covered in languages/javascript/27-event_loop: each await jobQueue.dequeue() yields control back to the event loop, which is what lets multiple in-flight jobs make progress concurrently even on one thread. True parallelism (using multiple CPU cores) requires actual separate processes or worker threads, each running its own pool.
Why the concurrency number is a real capacity decision, not a knob to maximize
Every job usually calls out to some shared downstream resource — a database connection pool, a rate-limited third-party API, disk I/O. Setting worker concurrency higher than that resource can sustain doesn't increase throughput; it just moves the bottleneck and adds contention (connection pool exhaustion, 429 rate-limit responses, disk thrashing). The correct concurrency for a worker pool is derived from the downstream resource's real capacity, not from how many jobs happen to be waiting.
Why "at-least-once + idempotent" is the actual contract, not a suggestion
A worker that dequeues a job, crashes mid-processing, and never acks leaves the broker no choice but to redeliver that job to another worker once a visibility timeout expires (this mechanism is broker-specific — RabbitMQ's unacked redelivery, Kafka's consumer group rebalance — but the shape is universal, covered in depth in the Message Brokers group). There is no way to distinguish, from the broker's side, "the worker is just slow" from "the worker died after doing half the work" — so every at-least-once system will eventually redeliver a job whose side effects already happened once. Idempotency isn't defensive programming for a rare edge case; it's the only way the "deliver at least once" contract can be made safe to build on top of.
⚠️ Common Pitfalls
- Writing a job handler that assumes exactly-once delivery — at-least-once is the norm for most brokers; a non-idempotent handler will eventually double-process a job when a worker crashes mid-run or a redelivery races an ack.
- Sizing worker concurrency by "how many jobs are in the queue" instead of "what the downstream resource can sustain" — over-concurrency just relocates the bottleneck to a database connection pool or a rate limit and adds contention.
- No job status tracking for work the caller needs an answer about — a queue is not a source of historical truth once a job is dequeued; tracked jobs need their own status record.
- Assuming jobs for the same entity process in order just because they were enqueued in order — concurrent workers can reorder them unless the system explicitly partitions by entity or checks versions.
- Treating "enqueued successfully" as "will definitely run soon" — a growing backlog with no backpressure or autoscaling can silently delay jobs by hours; queue depth needs monitoring, not just queue existence.
📋 Quick Reference
Pattern: accept fast -> enqueue -> respond -> worker pool processes independently later
Fire-and-forget: caller never checks the outcome
Tracked async job: caller polls a status record
Async + notification: system pushes the outcome back (webhook/WS/another event)
Idempotency: REQUIRED — at-least-once delivery means every handler can run more than once
Concurrency: bounded by the downstream resource's real capacity, not queue depth
Ordering: not guaranteed across concurrent workers unless partitioned by entity or versioned
Cross-ref: in-process async mechanics -> languages/javascript/27-event_loop, 29-promises
unbounded backlog growth -> next topic, Backpressure🧠 Key takeaways
- Accept fast, enqueue, respond; a bounded worker pool processes jobs independently.
- Tracked jobs need their own status record (the queue forgets jobs once acked).
- Delivery is at-least-once, so every handler must be idempotent.
- Concurrent workers don't preserve order: partition by entity or check versions; watch queue depth.
🎤 Interview Q&A
Q1When should work be processed asynchronously?core▾
When it's slow (sending emails, generating reports, video processing), not needed for the immediate response, bursty, or depends on unreliable third parties. The API enqueues a job and returns quickly (often 202 Accepted with a job ID).
- How does the client get the result? Polling a status endpoint, a webhook, or a push notification/WebSocket.
Q2What happens if a worker crashes mid-job?core▾
With at-least-once queues, the message wasn't acknowledged, so it becomes visible again after a timeout and another worker retries it. Jobs must therefore be idempotent, and long jobs should extend their visibility timeout or checkpoint progress.
- How do you make a job idempotent? Use a job ID to record completion, and make side effects conditional on that record.
Q3How do you handle jobs that keep failing?core▾
Retry with exponential backoff and a max attempt count, then move to a dead-letter queue with the error for inspection and replay. Distinguish transient errors (retry) from permanent ones (fail fast).
- What do you monitor? Queue depth, age of the oldest message, failure rate, DLQ size.
Q4How do you scale workers?senior▾
Scale on queue depth or message age rather than CPU (e.g. KEDA in Kubernetes), cap concurrency to protect downstream systems, and use separate queues for different priorities or job types.
- Why cap concurrency? Unlimited workers can overwhelm a shared database.
Q5How do you guarantee the job is enqueued if the request's database write succeeds?core▾
Use the transactional outbox: write the job to an outbox table in the same transaction, and a relay publishes it to the queue. Otherwise a crash between the DB commit and the enqueue loses the job.
- Alternative? Change data capture on the business table.
🛠️ Practice exercises
Each exercise is a file in this topic folder. Code exercises are self-checking: run them and make the checks pass.
✅ Check yourself
Answer the questions — feedback is instant.