Full-Stack Questions

Queues, Background Jobs and Webhooks

Why queues, BullMQ, retries and backoff, dead letters, idempotency, the outbox pattern, receiving and sending webhooks, RabbitMQ vs Kafka vs SQS.

Lesson 1930 min read#queues#webhooks

On this page

  1. Words used in this lesson
  2. Why use a queue
  3. BullMQ in practice
  4. Retries, backoff and dead letters
  5. Idempotency: the most important idea
  6. At-least-once, at-most-once, exactly-once
  7. Workers, concurrency and shutdown
  8. Scheduled jobs and cron
  9. The outbox pattern
  10. Webhooks: receiving them
  11. RabbitMQ, Kafka, SQS: which one?
  12. Serverless: where do background jobs run?
  13. Common mistakes
  14. Interview questions
  15. Key takeaways
  16. What's next

In one line: a queue lets you say "do this later, in the background" and answer the user now. Every queue can deliver a job more than once, so the real skill is making each job safe to repeat, and knowing what to do when a job keeps failing.

At 4+ years you have used a queue, a cron job or a webhook. Interviewers want to know whether you know what happens when they go wrong. A worker crashes in the middle of a job. A provider sends the same event twice. A cron job runs on three servers at once. This lesson explains the ideas and runs real code against BullMQ 6.3 (on Redis 7) and a webhook endpoint.

For the design side (it has a section on job queues with retries) read Design Problems, Lesson 07. For safe retries on HTTP APIs read REST API Design. The Stripe and BullMQ basics are in Libraries, Lesson 13. This lesson goes deeper.

Words used in this lesson

  • A queue is a waiting line of jobs. A producer adds to the back and a consumer takes from the front. Like numbered tickets at a bakery.
  • A job is one unit of background work with its data, such as "send the welcome email to user 7".
  • A producer is the code that adds jobs. A worker (or consumer) is a program that takes jobs and runs them.
  • A broker is the server that stores queues and hands out messages. Redis, RabbitMQ and Amazon SQS are brokers.
  • Backoff means waiting longer between each retry. Jitter is a small random amount added to the wait, so many failed jobs do not all retry at the same moment.
  • A dead-letter queue (DLQ) is a separate place for jobs that failed too many times. Like the "return to sender" box at the post office: a person looks at it later.
  • A poison message is a message that fails every time (for example, bad data), so retrying can never help.
  • Idempotent means doing something twice has the same result as doing it once. Setting a status to paid is idempotent. Adding 10 to a balance is not.
  • A webhook is an HTTP request that another service sends to your server when something happens, such as "payment succeeded".
  • A cron expression is a short schedule text, such as 0 3 * * * for "every day at 03:00".
  • HMAC (hash-based message authentication code) is a short value computed from a message and a shared secret. If the message changes, the value no longer matches. It proves the sender knew the secret.

Why use a queue

Without a queue the request does everything while the user waits: save the order, send the email, call the invoice service. A queue helps in four ways:

ProblemHow a queue helps
Slow requestsReturn 202 Accepted now, do the slow work later
A weak partner service (the email provider is down)The job waits and retries. The user's request still works
Traffic spikesThe queue absorbs the spike and workers go at their own speed (load leveling)
Work that must survive a crashJobs live in the broker, not in one web process

The costs are real: one more system to run, "pending" states in the UI, harder debugging, and duplicates to handle.

Do not queue everything. If the user needs the result in this request, or the call is fast and safe, just await it. Use a queue when the work is slow, flaky, bursty or must not be lost.

BullMQ in practice

BullMQ is a Node.js library that stores jobs in Redis (an in-memory database) and runs them with workers. It is the most used job queue in the Node ecosystem.

JavaScript
import { Queue, Worker } from 'bullmq'const connection = { host: '127.0.0.1', port: 6379, maxRetriesPerRequest: null }export const emails = new Queue('emails', { connection })// web app (producer)await emails.add('welcome', { userId: 7 })// separate worker process (consumer)new Worker('emails', async job => {  await sendWelcomeEmail(job.data.userId)}, { connection, concurrency: 5 })

Defaults I checked in the BullMQ docs and source:

  • attempts is 1, so there are no retries unless you set it.
  • A worker runs one job at a time unless you set concurrency.
  • Finished jobs are kept forever. Set removeOnComplete and removeOnFail or Redis fills up.
  • Redis needs maxmemory-policy noeviction and persistence (AOF). If Redis evicts keys, the queue breaks silently.
  • Job data is plain text in Redis. Put an ID in the job, not secrets or card numbers.

Retries, backoff and dead letters

Most failures are temporary (a timeout, a 503). Retrying with a growing wait fixes them. BullMQ has fixed and exponential backoff and an optional jitter from 0 to 1. With exponential the wait is 2^(attempt-1) * delay.

Some failures are permanent (an invalid email address). Throw UnrecoverableError and BullMQ fails the job at once, even if attempts remain. BullMQ's docs do not describe a built-in dead-letter queue. After the last attempt the job stays in the failed set, where you can inspect it and call job.retry(). For a real DLQ, copy it yourself. This is what I ran:

JavaScript
import { Queue, Worker, UnrecoverableError, Job } from 'bullmq'const dead = new Queue('emails-dead', { connection })const worker = new Worker('emails', async (job: Job) => {  if (job.name === 'bad-address') throw new UnrecoverableError('invalid email')  if (job.name === 'always-down') throw new Error('provider down')}, { connection })// 'failed' fires after EVERY failed attempt, so check if this was the last oneworker.on('failed', async (job, err) => {  if (!job) return  const last = job.attemptsMade >= (job.opts.attempts ?? 1) || err instanceof UnrecoverableError  if (last) await dead.add('dead', { name: job.name, data: job.data, reason: err.message })})await emails.add('always-down', { to: 'c@d.co' },  { attempts: 3, backoff: { type: 'exponential', delay: 500 } })

Result: always-down ran at about 0 s, 0.7 s and 1.8 s (waits of 500 ms and 1,000 ms), then landed in the dead queue. bad-address ran once and went straight there. A flaky job that failed twice succeeded on attempt 3.

Always throw an Error object. BullMQ's docs warn that throwing a string or other value breaks its handling.

A DLQ is not a trash can. Someone must watch it (alert when it is not empty), read why jobs failed, fix the bug and replay them.

Idempotency: the most important idea

Queues can deliver a job twice (next section). The only reliable defense is to make the work idempotent. Three ways, from weakest to strongest:

  1. Naturally safe operations: UPDATE orders SET status = 'paid', upserts, "create if not exists". Avoid balance = balance + 10.
  2. A unique key in your database. Before the side effect, insert a row with a unique key (the event ID, or orderId + 'welcome-email'). If the insert fails, skip the work. This works across crashes and servers.
  3. The provider's idempotency key. Stripe and many APIs accept one, so a repeated call does not charge twice.
JavaScript
// only the FIRST delivery sends the emailconst r = db.prepare('INSERT OR IGNORE INTO emails_sent (order_id) VALUES (?)').run(job.data.orderId)if (r.changes === 1) await sendEmail(job.data.orderId)

There is a gap: if the process crashes after the insert but before the email, the retry skips the email. Choose the order by what hurts more, a lost email or a double one. For money, use the provider's key too.

BullMQ also helps when adding jobs:

  • jobId: adding a second job with the same jobId returns the existing job. Gotcha: only while the job still exists. With removeOnComplete: true I added the same jobId after the first job finished and it ran twice. With removeOnComplete: false it ran once.
  • deduplication: { id, ttl } ignores new jobs with the same id for ttl ms (or until the first job finishes, if you give no ttl). I added three jobs with one id: one stayed and the deduplicated event fired twice. In BullMQ 6 the old debounce option is gone; use deduplication.

At-least-once, at-most-once, exactly-once

NameMeaningHow it happens
At-most-once0 or 1 times. Can be lost, never repeatedAcknowledge first, then work. Or no retries
At-least-once1 or more times. Never lost, can repeatWork first, then acknowledge. Retry if no ack
Exactly-onceExactly 1 timeOnly in special closed setups

An acknowledgement (ack) is the consumer telling the broker "I finished, you may delete it". If the consumer crashes before the ack, the broker gives the message to someone else. That is why duplicates exist: the work may be done with only the ack missing.

Exactly-once delivery over a network is not possible in the general case, because the sender cannot tell "the message was lost" from "the answer was lost". What you can build is effectively once: at-least-once delivery plus an idempotent consumer. Kafka's own docs say to read the fine print on exactly-once claims. Its exactly-once covers reading from a topic and writing to another Kafka topic (with transactions). A write to your database or an email is outside it.

I proved the crash case. I started a worker with a 2-second lockDuration on a long job and killed it with kill -9. A second worker logged the job as stalled and started it again with attemptsStarted=2. Nothing was lost, and the job ran again.

Workers, concurrency and shutdown

Concurrency is the number of jobs one worker runs at once. It uses the Node.js event loop, so it helps with I/O-bound jobs (waiting on the network or database). I ran 10 jobs that each wait 200 ms: about 2,050 ms with concurrency: 1 and about 430 ms with concurrency: 5. The BullMQ docs call 100 to 300 normal for I/O jobs, but tune it in production.

For CPU-bound jobs (image resizing) concurrency does not help, because JavaScript runs on one thread. Add worker processes or machines, or use a sandboxed processor (your function runs in a separate process). Workers scale out because each job goes to only one worker.

Stalled jobs. A worker keeps a lock on its active job and renews it. The default lockDuration is 30 seconds. If the lock is not renewed (the process died, or a CPU loop blocked the event loop), the job is stalled and goes back to waiting. If it stalls more than maxStalledCount times (default 1), it fails with "job stalled more than allowable limit". So a long synchronous loop can make a healthy job look dead.

Graceful shutdown. On a deploy the platform sends SIGTERM (a polite "please stop"). Call worker.close(): it takes no new jobs and waits for the running ones. In my test it waited about 830 ms for a one-second job.

JavaScript
process.on('SIGTERM', async () => {  await worker.close()  process.exit(0)})

If a job outlasts the grace period (30 seconds by default in Kubernetes), the process is killed and the job stalls. Keep jobs short or split them into steps.

Also useful: limiter: { max: 10, duration: 1000 } limits the whole queue across all workers (some pages in the BullMQ docs still show the old QueueScheduler, which is not needed since BullMQ 2.0). priority runs important jobs first. delay runs a job at least that many ms later. The metric to watch is the age of the oldest waiting job: it shows when workers cannot keep up.

Scheduled jobs and cron

Typical cron work: nightly reports, cleanup, reminders. The hard parts are not the syntax:

  • Time zones. Server cron often uses UTC. "Every day at 9" for users in Berlin moves when daylight saving time changes. Choose a zone on purpose.
  • Many instances. setInterval or node-cron inside a web app that runs three copies fires three times. Run the scheduler in one place, use a lock, or let the queue do it.
  • Overlap. If a run is longer than the interval, the next one starts while the first is still going.
  • Missed runs. If the server is down at 03:00, plain cron does not run later. Process "everything since the last success", not just "yesterday".

In BullMQ use a Job Scheduler. It replaces the old "repeatable jobs", whose API is removed in BullMQ 6. upsertJobScheduler creates or updates by id, so deploying twice does not make two schedulers:

JavaScript
await queue.upsertJobScheduler(  'nightly-report',  { pattern: '0 0 3 * * *', tz: 'UTC' },   // BullMQ cron has a seconds field first  { name: 'report', data: {}, opts: { attempts: 3, removeOnFail: 1000 } },)

I ran one with every: 1000 and upserted it twice with the same id: getJobSchedulers() showed one scheduler and it made about one job per second. The docs note the next job is created when the last one starts, so a busy queue can run it less often than planned. The jobs go through normal workers, so retries and concurrency apply.

The outbox pattern

The problem: you save an order, then add a job to the queue. These are two systems and cannot share one transaction. A crash between them leaves an order with no email job. This is the dual write problem.

The outbox pattern fixes it. In the same database transaction that saves the order, also insert a row into an outbox table. A separate relay reads unsent rows, publishes them to the queue and marks them sent.

JavaScript
db.exec('BEGIN')db.prepare('INSERT INTO orders (id, total) VALUES (?, ?)').run(id, total)db.prepare('INSERT INTO outbox (type, payload) VALUES (?, ?)')  .run('order.created', JSON.stringify({ orderId: id }))db.exec('COMMIT')      // both rows, or neither// relay: publish, THEN mark as sentfor (const row of unsentRows) {  await queue.add(row.type, JSON.parse(row.payload), { jobId: `outbox-${row.id}` })  db.prepare("UPDATE outbox SET sent_at = datetime('now') WHERE id = ?").run(row.id)}

I ran this on SQLite. A failed order rolled back with no outbox row. When I made the relay "crash" after publishing row 1 and before marking it, the restart published row 1 again, the same jobId kept it to one job, and the consumer's unique key sent each email once. So the outbox gives at-least-once, and consumers must still be idempotent. The Meilisearch sync in Lesson 12 uses the same idea.

Trade-offs: a small delay (the relay polls), a table to clean, one more process. Alternative: change data capture (a tool reads the database log and publishes changes).

Webhooks: receiving them

A webhook endpoint is a public URL, so anyone can call it. Six rules:

  1. Read the raw body. Signatures cover the exact bytes. I checked: a signature made over a pretty-printed body failed after JSON.stringify(req.body). In Express use express.raw({ type: 'application/json' }) on that route; in a Next.js Route Handler use await request.text().
  2. Verify the HMAC with a constant-time comparison (crypto.timingSafeEqual), not ===. A normal comparison stops at the first different character, and its timing can leak information.
  3. Check the timestamp. Providers often sign timestamp.body. Reject old timestamps (Stripe's default tolerance is 5 minutes). This stops replay: re-sending a captured valid request.
  4. Answer fast with 2xx. Verify, save or queue the event, return 200, work in a worker. A slow answer counts as a failure and is retried.
  5. Be idempotent. Store the event ID with a unique constraint. For one you have seen, return 200 (an error makes them retry).
  6. Do not trust the order. Treat the event as a hint, then fetch the current object from the provider API and write that.
JavaScript
app.post('/webhooks/pay', express.raw({ type: 'application/json' }), async (req, res) => {  if (!verify(req.body, req.header('x-signature'))) return res.status(400).send('bad signature')  const event = JSON.parse(req.body.toString('utf8'))  try {    db.prepare('INSERT INTO processed_events (id) VALUES (?)').run(event.id)  // unique key  } catch {    return res.status(200).send('duplicate, ignored')  }  await queue.add(event.type, event, { jobId: `evt-${event.id}` })  res.status(200).send('queued')})

I ran five requests. A valid one gave 200 queued; the same again gave 200 duplicate, ignored; a tampered body, a one-hour-old timestamp and a missing header each gave 400. One job was queued. My verify parses t=<seconds>,v1=<64 hex>, checks the age, recomputes HMAC-SHA256(secret, t + '.' + body) and uses timingSafeEqual.

Real providers have their own header names and formats. Stripe has stripe.webhooks.constructEvent(rawBody, signatureHeader, secret) and GitHub uses X-Hub-Signature-256. Use the official helper and read the retry rules: Stripe retries for up to three days in live mode, while other providers retry only a few times or never.

Sending webhooks makes you the provider. Sign every request and include a timestamp and a unique event ID. Send from a queue, with exponential backoff and jitter over hours, and a short timeout. Never call private IP addresses (SSRF: a customer could aim your server at your internal network). Disable endpoints that fail for days, and give customers a delivery log and a replay button.

RabbitMQ, Kafka, SQS: which one?

RabbitMQApache KafkaAmazon SQS
ModelSmart broker: exchanges route messages to queuesA distributed log split into partitionsSimple managed queue
After consumeRemoved after ackStays until retention ends. Consumers track an offsetYou delete it after success
ReplayNoYes, reset the offsetNo
OrderPer queuePer partition onlyStandard: best effort. FIFO queue: per message group
DeliveryAt-least-once with acksAt-least-once by defaultStandard: at-least-once
Failed messagesDead-letter exchange. Quorum queues have a delivery-limit (default 20 since 4.0)You build it (retry and DLQ topics)DLQ with a redrive policy
OperationsYou run itHeaviestNo servers

How I choose:

  • BullMQ / Redis: Node app with jobs, delays, cron, retries. Fast to start. Risk: Redis is memory-first, so set persistence and noeviction.
  • SQS: on AWS, no servers to run, with a DLQ and Lambda as consumer.
  • RabbitMQ: complex routing (topic exchanges, fan-out), many languages, priorities.
  • Kafka: an event log: many independent readers, replay, stream processing, very high volume. Overkill for "send an email later".

Two Kafka words: a consumer group is a set of consumers sharing a topic, where each partition goes to one member. An offset is a message's position in a partition. Since order exists only inside a partition, pick a key such as userId to keep related events together. I checked two defaults in the source: producer idempotence is on, and the consumer's enable.auto.commit is also on, which can commit a position before your work is done. Many teams turn it off and commit after processing.

For SQS, the visibility timeout (default 30 seconds) hides a received message from others. If you do not delete it in time, it reappears. Set it longer than your slowest job.

Serverless: where do background jobs run?

A serverless function lives for a request and has a time limit, so a long-lived BullMQ worker does not fit.

NeedOption
Small work after the response (logging, analytics)Next.js after(). It runs after the response, within the route's max duration, even if the response errors. Not a queue: if the process dies, the work is gone
Scheduled workPlatform cron (for example Vercel Cron). Its docs say delivery is best effort: a run can be missed or happen twice, a failed call is not retried, and runs can overlap. Make the handler idempotent and use a lock
Durable queue on AWSSQS + Lambda. Set the visibility timeout above the function timeout and use partial batch failure
Long multi-step flowsA workflow service (Step Functions, Temporal, Inngest, Trigger.dev). Some are newer, so I would test on a small flow first
Heavy or long workAn always-on worker in a container next to the app

Common mistakes

  • A worker that is not idempotent, so each retry or crash makes a duplicate email or charge.
  • attempts with no backoff, which hammers a service that is already down. Retrying errors that can never succeed.
  • Nobody looks at failed jobs or the DLQ.
  • Redis with eviction on, no persistence, or finished jobs kept forever.
  • Parsing the webhook body as JSON before verifying, comparing with ===, skipping the timestamp.
  • Cron inside a multi-instance web app. Database write and queue add as two steps, with no outbox.
  • Trusting webhook order.

Interview questions

Why would you add a queue to an app? (Must know)

I add a queue when the work is slow, can fail, comes in bursts or must not be lost, and the user does not need the result in the same request. Examples: emails, image resizing, partner APIs. The request saves the data, adds a job and returns 202. A worker does the job and retries on failure. The benefits are a faster response, protection from slow partners, and load leveling. The costs are another system to run, "pending" UI states, harder debugging and duplicates. If the user needs the answer now, or the call is fast and safe, I do it in the request.

What is the difference between at-most-once, at-least-once and exactly-once? Which do queues give? (Must know)

At-most-once: a message may be lost but is never repeated (ack first, work second). At-least-once: never lost, but can repeat (work first, ack second; the broker redelivers without an ack). Exactly-once: one time only. Most queues give at-least-once. True exactly-once delivery is not possible in general, because the sender cannot tell a lost message from a lost answer. What I build is effectively once: at-least-once delivery plus an idempotent consumer.

My BullMQ job sent two emails. Is that a bug in BullMQ? (Trap)

Usually not. It is normal at-least-once behavior. A worker can crash or stall after sending the email but before the job is marked done, and then the job runs again. I reproduced it by killing a worker with kill -9: another worker ran the job a second time. The fix is in my handler: record "email sent for order 7" with a unique key and skip if it exists, or use the email provider's idempotency key. I also look for stall causes, such as a long synchronous loop, and I stop the producer from adding the same job twice with jobId or deduplication.

How do you make a background job idempotent? (Must know)

Three levels. First, naturally safe operations: set a status instead of adding to a balance, use upserts. Second, a unique key in the database: insert (eventId) or (orderId, 'welcome-email') before the side effect and skip if the insert fails. That works across servers and crashes. Third, pass an idempotency key to external APIs like payment providers. I also think about the order of "record" and "do": record-then-do can lose the action on a crash; do-then-record can repeat it. For money I use the provider's key plus my own record.

How do retries and backoff work? How do you pick settings? (Must know)

In BullMQ I set attempts: 5 and backoff: { type: 'exponential', delay: 1000, jitter: 0.5 }. The wait is 2^(attempt-1) * delay, so 1 s, 2 s, 4 s, 8 s, and jitter spreads retries so many jobs do not hit a recovering service together. I choose the total window for the failure I expect: seconds for a network blip, hours for a provider outage. I retry only temporary errors (timeouts, 5xx, 429). For permanent errors such as invalid input, I throw UnrecoverableError so the job fails at once.

What is a dead-letter queue, and what is a poison message? How do you do it with BullMQ? (Must know)

A DLQ holds messages that failed too many times, so they do not block the queue and a human can look at them. A poison message is one that always fails, such as malformed data. RabbitMQ has dead-letter exchanges (and a quorum-queue delivery-limit, default 20 since version 4.0), and SQS has a redrive policy. BullMQ's docs do not describe a built-in DLQ: failed jobs stay in the failed set and I can call job.retry(). For a separate queue I listen to failed, check that attemptsMade reached attempts (the event fires on every failed attempt), and copy the job and error to a *-dead queue. The real work is operations: alert when it is not empty, find the cause, fix and replay. I also validate data when adding the job, so bad data is rejected early.

Concurrency or more workers? How do you scale? (Senior signal)

First I decide if the job is I/O-bound or CPU-bound. For I/O, a higher BullMQ concurrency helps a lot. I measured 10 jobs of 200 ms: about 2 s at concurrency 1, 0.43 s at 5. For CPU work concurrency adds nothing since JavaScript runs on one thread, so I add worker processes or machines, or use sandboxed processors. I watch queue depth and the age of the oldest job. I also respect downstream limits: 50 workers can overload a database or a rate-limited API, so I use the limiter option or lower concurrency.

What are stalled jobs? (Trap)

A worker holds a lock on its active job and renews it; the default is 30 seconds. If the lock is not renewed, because the process died or a CPU loop blocked the event loop, the job is stalled and goes back to waiting. If it stalls more than maxStalledCount (default 1) it fails. The trap is that a healthy but blocking job looks dead and runs twice. I avoid long synchronous work in handlers, use sandboxed processors for CPU work, and keep jobs idempotent anyway.

How do you shut a worker down during a deploy? (Must know)

I listen for SIGTERM and call await worker.close(). It takes no new jobs and waits for the active ones. In my test it waited about 830 ms for a one-second job. If a job is longer than the platform's grace period (30 seconds by default in Kubernetes), the process is killed and the job stalls and reruns. So I keep jobs short or split them, set the grace period to fit, and keep jobs idempotent.

How do you run a nightly job safely with several servers? (Senior signal)

Not with node-cron in the web app, because three instances run it three times. I use a scheduler that keeps the schedule in shared state: in BullMQ, upsertJobScheduler with a cron pattern and a tz. Upserting by id means a redeploy does not duplicate it, and the job goes through normal workers with retries. I choose the time zone on purpose because daylight saving moves local times. I make it safe if it overlaps or runs twice (a lock or an idempotent design) and able to catch up: process everything since the last success.

What is the outbox pattern and why do you need it? (Senior signal)

It solves the dual write problem. Saving to the database and publishing to a queue are two steps, so a crash between them loses an event or sends one for rolled-back data. I insert the event into an outbox table in the same transaction as the business data. A relay reads unsent rows, publishes them and marks them sent. The relay can crash after publishing and before marking, so the event may be published twice. So the pattern gives at-least-once, consumers must be idempotent, and I use the outbox row id as the jobId. Alternatives: change data capture, or accepting the small risk for low-value events.

How do you secure a webhook endpoint? (Must know)

I verify an HMAC signature over the raw body with the shared secret, using a constant-time comparison. That needs the raw bytes, so I skip the JSON parser on that route (express.raw, or request.text() in Next.js). I check the timestamp against a tolerance of a few minutes to stop replays. I use HTTPS, keep the secret out of code, and can rotate it. For money I load the object from the provider's API instead of trusting the payload. When the provider has an official helper, such as stripe.webhooks.constructEvent, I use it.

A webhook signature check passes in tests but fails in production. What do you check? (Trap)

Most likely the body changed before verification. I look for express.json() or another parser running first, or any re-serialization. I reproduced it: a signature made over a pretty-printed body failed after JSON.stringify(req.body). Then I check I use the right secret for this endpoint and mode (test versus live), that the server clock is correct, that a proxy or middleware does not change the body (compression, encoding), and that I read the header name exactly as documented.

Webhooks can repeat and arrive out of order. How do you cope? (Senior signal)

For repeats I store the event ID with a unique constraint and return 200 for one I have seen, so the provider stops retrying; the work itself is idempotent too. For order, I do not use arrival order or the payload timestamp, since two events can share one second. I treat the event as a notification, fetch the current object from the API and write that state. For critical flows I add a reconciliation job that compares my data with the provider's, because events can be missed once retries run out.

Why must the webhook endpoint answer quickly? What about slow work? (Must know)

The provider waits only a short time. A slow handler counts as a failure and is retried, which gives duplicates while the first run is still going. So the endpoint does the minimum: verify, save or queue the event, return 2xx. The real work runs in a worker with its own retries. If I cannot even queue the event, I return a 5xx so the provider retries. The event-ID unique key makes the duplicates harmless.

RabbitMQ, Kafka or SQS? How do you choose? (Senior signal)

I start from the need. Background jobs in a Node app with delays, cron and retries: BullMQ on Redis. On AWS with no wish to run servers: SQS with a DLQ and Lambda. Complex routing, many languages or priorities: RabbitMQ. An event log that many services read, with replay or very high volume: Kafka. The key difference is that in RabbitMQ and SQS a message is deleted once handled, but in Kafka it stays for the retention period and each consumer group tracks its own offset, so I can replay. Kafka orders only inside a partition, so I choose a key. It is heavy to run, so I would not use it for "send an email later".

You are on Vercel with serverless functions. How do you do background jobs? (Senior signal)

I split by need. Small work that can be lost, like logging, uses Next.js after(), which runs after the response within the route's max duration. Schedules use platform cron, which I treat as best effort: the docs say a run can be missed or sent twice and a failed call is not retried, so the handler is idempotent, has a lock against overlap, and checks "what is still not done". Durable work goes to an external queue: SQS with a consumer, or a workflow service for long multi-step jobs. A BullMQ worker needs a long-running process, so I would run it in a container next to the app.

Design: a user buys something and gets an email, an invoice and a stock update. Walk through it. (Senior signal)

In one transaction I save the order and an order.created outbox row, and return 201. A relay publishes the event to the queue with the outbox id as the jobId. Three separate jobs run (email, invoice, stock), so one failing does not block the others. Each has attempts, exponential backoff and jitter, and is idempotent with a unique key such as (orderId, 'invoice'). Permanent errors use UnrecoverableError, and exhausted jobs go to a watched dead-letter queue. For payment I trust only the verified, de-duplicated webhook. I monitor queue depth, the age of the oldest job and failures, and run a nightly reconciliation job to catch anything missed. For stock I use one atomic update, not "read, then write".

Key takeaways

  • Use a queue for slow, flaky, bursty or must-not-lose work. Return fast, work later.
  • Every queue can deliver twice. Build at-least-once + idempotent ("effectively once"). Tools: natural idempotency, a unique key, the provider's key. jobId and deduplication only help when adding jobs.
  • Retries need attempts, exponential backoff with jitter and UnrecoverableError for permanent failures, plus a dead-letter step that someone watches.
  • Concurrency helps I/O jobs, not CPU jobs. Shut down with worker.close(). Do not block the event loop, or jobs stall.
  • Cron: run it in one place or use upsertJobScheduler. Decide the time zone; handle overlap and missed runs.
  • Outbox fixes the dual write problem: event row and business row in one transaction, then a relay publishes.
  • Webhooks: raw body, HMAC with constant-time compare, timestamp check, fast 2xx, event-ID dedupe, never trust order.
  • BullMQ for Node jobs, SQS for simple managed queues, RabbitMQ for routing, Kafka for event logs and replay.
  • Serverless: after() for small lossy work, platform cron as best effort, an external queue or workflow service for durable work.

What's next

Libraries, Lesson 13 covers the tools beside queues: Redis caching, Stripe and TanStack Query. For a design that includes job queues with retries, see Design Problems, Lesson 07.

Published by Jainik · Last updated · How these lessons are made

Keep learning

Related lessons

Part of the study guide

Related series