What you'll learn

By the end of this you'll understand why queues exist, what problems they solve that direct service calls don't, how delivery guarantees work in practice, and what to do when a message can't be processed. You'll also know the difference between a queue and a topic, when to reach for each, and how backpressure prevents one slow consumer from taking down your entire system.

This isn't a guide to a specific tool. It's the concepts behind all of them — Kafka, RabbitMQ, SQS, Cloud Pub/Sub — so you can reason through queue-based architecture whether you're in an interview or in a production incident.

Who this is for

  • Backend engineers who've integrated a job queue for async processing but want to understand the broader design implications
  • Engineers preparing for system design interviews where a queue is clearly part of the answer but the details aren't obvious
  • Anyone who's had a slow downstream service cascade failures upstream and wants to understand how decoupling solves that

You can skip this if you've designed multi-consumer event-driven architectures with dead letter queues, backpressure strategies, and at-least-once processing semantics as core requirements.

What are message queues?

A message queue is a buffer between a producer (the service that creates work) and a consumer (the service that does the work). The producer publishes a message and moves on immediately. The consumer reads messages at its own pace and processes them independently.

Plain English: A queue is an inbox for work your system needs to do later. When someone signs up, you don't send the welcome email synchronously — you drop a message in a queue saying "send welcome email to user 123" and return the signup response immediately. A background worker picks up the message and sends the email when it's ready.

Simple idea: publish now, process later. The producer doesn't wait for the consumer, and the consumer doesn't need the producer to be available when it processes the work.

Prerequisites

  • Basic understanding of microservices or multi-service architectures
  • Familiarity with at least one queue system (SQS, RabbitMQ, BullMQ, or similar)
  • Node.js or any backend language for the working examples

Setup from zero

Step 1 — Decide if you actually need a queue

Queues add operational complexity. Before reaching for one, ask: does the caller need the result of this operation right now?

Good candidates for async queue processing:

  • Sending a welcome email after signup
  • Resizing an uploaded image
  • Webhook delivery to third parties
  • Report generation ("we'll email it to you")

Poor candidates:

  • Looking up a user's account balance before displaying it
  • Validating form input
  • Anything where the next step depends on the result of this step

The diagnostic question: can the user or the calling system do something useful while this work happens in the background? If yes, queues make sense. If no, a direct call is clearer.

Step 2 — Understand delivery guarantees

At-most-once — the message is delivered zero or one time. If the consumer crashes after receiving but before processing, the message is lost. Appropriate only for data you can afford to lose — metrics, telemetry.

At-least-once — the message is delivered one or more times. The queue re-delivers if it doesn't receive an acknowledgment. This is the default in most production systems. Your consumer must be idempotent — processing the same message twice must produce the same outcome as processing it once.

Exactly-once — the message is delivered exactly one time. Extremely difficult in distributed systems and usually involves significant overhead. Most systems achieve "effectively exactly-once" by combining at-least-once delivery with idempotent consumers.

Step 3 — Set up a dead letter queue and handle backpressure

A dead letter queue (DLQ) is where messages go when they can't be processed successfully after a configured number of attempts. Without a DLQ, a poison message loops forever, consuming retries and blocking progress.

Backpressure is what happens when producers generate messages faster than consumers can process them. Without a strategy, the queue grows without bound until you run out of memory or disk.

> Little tip: Design your consumers to be idempotent from day one, even if your queue claims to offer exactly-once. Network partitions and re-delivery edge cases happen in practice, and an idempotent consumer is a much more reliable safety net than a delivery guarantee.

The mental model

The mental model for queues is temporal decoupling.

When you make a direct synchronous call, you're saying: "I need this work done right now, and I'll wait." When you publish to a queue, you're saying: "I need this work done eventually, and I don't need to watch it happen." That's the core trade-off — synchrony and immediacy on one side, resilience and independence on the other.

Everything else about queues — delivery guarantees, dead letter queues, backpressure, ordering guarantees, consumer groups — is a consequence of that fundamental trade-off and the new failure modes it introduces. Once you have a queue, you need to think about what happens when the consumer is slow, when it crashes, when a message is malformed, and when the queue fills up.

Key terms

Queue — delivers each message to exactly one consumer.

Topic / pub-sub channel — delivers each message to all current subscribers.

Producer — the service that creates and sends messages to the queue.

Consumer — the service that reads and processes messages from the queue.

Acknowledgment (ack) — a signal from the consumer that a message was successfully processed and can be deleted.

At-least-once delivery — the queue guarantees the message will be delivered at least once, but possibly more. Requires idempotent consumers.

Dead letter queue (DLQ) — a queue where messages are sent after repeatedly failing to be processed successfully.

Backpressure — the mechanism by which a slow consumer communicates capacity limits back to producers.

Idempotency — an operation that produces the same result whether executed once or multiple times.

Poison message — a message that causes processing to consistently fail, typically due to malformed data or a bug in the consumer.

Step-by-step: publish and consume with SQS

Producer — publish a job after signup:

import { SQSClient, SendMessageCommand } from "@aws-sdk/client-sqs";

const sqs = new SQSClient({ region: process.env.AWS_REGION });

async function enqueueWelcomeEmail(userId: string, email: string) {
  await sqs.send(new SendMessageCommand({
    QueueUrl: process.env.WELCOME_EMAIL_QUEUE_URL,
    MessageBody: JSON.stringify({ userId, email, type: "welcome" }),
  }));
}

Consumer — process with idempotency:

async function processMessage(message: SQSMessage) {
  const { userId, email, type } = JSON.parse(message.Body!);

  // Idempotency check — skip if already sent
  const alreadySent = await db.emailLog.exists({ userId, type });
  if (alreadySent) {
    await sqs.deleteMessage({ QueueUrl, ReceiptHandle: message.ReceiptHandle });
    return;
  }

  await sendWelcomeEmail(email);
  await db.emailLog.create({ userId, type, sentAt: new Date() });
  await sqs.deleteMessage({ QueueUrl, ReceiptHandle: message.ReceiptHandle });
}

Working examples

Message lifecycle with DLQ:

message → queue → consumer attempts processing
  success → acknowledge → message deleted from queue
  failure → return to queue → retry (up to N times)
  N failures → move to dead letter queue

Queue vs topic:

Queue:   order.created → exactly ONE worker processes it
Topic:   order.completed → email service, inventory service, analytics service ALL receive it

Idempotent processing pattern:

async function handlePaymentWebhook(eventId: string, payload: PaymentEvent) {
  const processed = await redis.set(`webhook:${eventId}`, "1", "EX", 86400, "NX");
  if (!processed) return; // already handled

  await applyPayment(payload);
}

Patterns / when to use each primitive

Use a queue when: you have a unit of work that should be done once — resize an image, send an email, generate a report.

Use a topic (pub/sub) when: you want to broadcast an event to multiple independent services — order.completed heard by email, inventory, and analytics.

Use at-least-once + idempotent consumers when: you need reliable delivery and can tolerate the complexity of deduplication logic. This is the production default.

Use backpressure (scale consumers or reject publishes) when: queue depth grows faster than processing capacity and you need to prevent unbounded growth.

> Little tip: Set a maximum retention time on your queue messages. A message that was valid 10 minutes ago might be meaningless or harmful to process 6 hours later. Include a timestamp in your message payload and skip processing if it's too old.

Common mistakes

Using a queue when the caller needs an immediate result — queues add latency and make error handling async. If the user is waiting for the answer, use a direct call.

Non-idempotent consumers with at-least-once delivery — duplicate messages cause duplicate emails, double charges, or inconsistent state. Always design for safe re-processing.

No dead letter queue — poison messages retry forever, blocking the queue and hiding bugs. Every production queue needs a DLQ with monitoring.

Ignoring queue depth — a growing queue is a leading indicator of a consumer problem. Alert on depth thresholds before the backlog becomes unrecoverable.

Troubleshooting

Messages stuck in the queue, not being processed — consumer is down, crashed on startup, or lacks permission to read from the queue. Check consumer health and IAM permissions.

Same message processed multiple times — at-least-once delivery is working as designed. Add idempotency keys or deduplication logic in the consumer.

DLQ filling up rapidly — poison messages with bad data or a consumer bug. Inspect DLQ messages manually, fix the bug or the data, then re-drive messages back to the main queue.

Queue depth growing without bound — consumer throughput is lower than producer rate. Scale consumers horizontally or apply backpressure to producers when depth exceeds a threshold.

Checklist

  • [ ] Confirmed the caller doesn't need an immediate result before choosing a queue
  • [ ] Consumers are idempotent — safe to process the same message twice
  • [ ] Dead letter queue configured with max receive count
  • [ ] DLQ depth monitored with alerts on non-zero count
  • [ ] Queue message retention set appropriately (not infinite)
  • [ ] Message payload includes a timestamp for staleness checks
  • [ ] Chose queue vs topic based on one-consumer vs broadcast semantics
  • [ ] Backpressure strategy defined for when queue depth exceeds threshold

Practice task

Implement a signup flow that enqueues a welcome email job instead of sending synchronously. Build a consumer that processes the queue with idempotency (check an email log before sending). Deliberately crash the consumer mid-processing and verify the email is sent exactly once after recovery. Configure a DLQ with max receive count of 3 and trigger a poison message to confirm it lands in the DLQ.

FAQ

Kafka vs SQS vs RabbitMQ — which should I use?

SQS is the simplest managed queue — great for job processing with minimal ops. RabbitMQ is a flexible message broker with routing, exchanges, and pub/sub. Kafka is a distributed log optimized for high-throughput event streaming with replay. Start with SQS for job queues; reach for Kafka when you need event streaming at scale.

How do I handle ordering guarantees?

Most queues don't guarantee strict ordering across partitions. If order matters (process events for user A in sequence), use a partition key (Kafka) or a single-consumer queue with concurrency 1. Accept that strict ordering limits throughput.

Should I use a queue or just setTimeout / a cron job?

A queue gives you durability (messages survive restarts), retry logic, DLQ, and horizontal scaling of consumers. setTimeout loses work on process crash. Cron jobs batch work on a schedule rather than processing events as they arrive. Queues are the right choice for event-driven async work.

What to learn next

With queue fundamentals solid: database replication and sharding for scaling the data layer your consumers write to, caching to reduce load on downstream services during queue processing bursts, and rate limiting to protect APIs that enqueue work.

  • System design: databases — scale the data layer your queue consumers write to
  • System design: caching — reduce downstream load during queue processing bursts
  • System design: rate limiting — protect APIs that enqueue async work from abuse

Takeaways

Queues decouple producers from consumers through temporal decoupling — publish now, process later. At-least-once delivery is the production default, which means idempotent consumers are non-negotiable. Every queue needs a DLQ, monitoring, and a backpressure strategy.

If you remember only one thing: if the caller doesn't need to wait for the result, a queue makes your system more resilient — but only if your consumers can safely handle the same message twice.