Skip to content
← Back to all lessons
Day 014 · July 8, 2026 System Design

Message queues — when the request can't wait for the work

A load balancer distributes requests synchronously. A message queue decouples them asynchronously — the producer fires and forgets, the consumer processes at its own pace.

14 min read

Day 7 taught you load balancing: distribute synchronous requests across servers. Day 9 taught you rate limiting: cap how fast clients can send. Today: message queues — when the client’s request can’t wait for the work to finish. If load balancing is about where work happens, and rate limiting is about how much gets through, a message queue is about when it gets done.

The problem: synchronous is fragile

You run an e-commerce app. A user clicks “Place Order.” Your server must:

  1. Validate the cart
  2. Charge the credit card (2-5 seconds via Stripe API)
  3. Decrement inventory
  4. Generate a PDF invoice
  5. Send a confirmation email (1-3 seconds via SMTP)
  6. Update the analytics dashboard

Total: 5-10 seconds of work. The user stares at a spinner. If the SMTP server is slow, the user waits even longer. If the connection drops at second 7, the order might be charged but the email never sends — and the user doesn’t know if the order went through.

This is the synchronous chaining problem. Every step blocks the next. The user’s HTTP request is held open for the entire chain. If any step is slow, the whole chain is slow. If any step fails, you need complex rollback logic (Day 10’s transactions help, but they don’t cover external API calls like email).

The fix: decouple with a queue

Instead of doing everything in the HTTP request, split the work into two phases:

  1. Synchronous (fast): validate the cart, charge the card, return “Order confirmed” to the user. This takes 3 seconds — acceptable for an HTTP request.
  2. Asynchronous (slow): put a message on a queue saying “Order #1234 placed — send email, generate PDF, update analytics.” A separate consumer picks it up and processes it later.

The user gets a response in 3 seconds instead of 10. The email, PDF, and analytics happen in the background. If the email server is slow, the user doesn’t care — they already got their confirmation.

The three core concepts

A message queue has three roles. Learn these terms — they appear in every interview answer:

  1. Producer — the system that creates a message and puts it on the queue. In our example, the app server publishes “Order #1234 needs email + PDF” after charging the card. The producer does not wait for the work to be done. It fires and forgets.
  2. Queue (broker) — the intermediary that stores messages. Messages are ordered (FIFO: first in, first out). The queue holds messages until a consumer is ready. If no consumer is online, messages wait. The queue is the buffer between fast producers and slow consumers.
  3. Consumer — the system that reads messages from the queue and processes them. There can be multiple consumers (workers) pulling from the same queue — this is how you scale processing. If one worker is busy, another picks up the next message.

What a message looks like in code

// Producer (your app server) — publish a message
const message = {
  type: "order_placed",
  orderId: 1234,
  customerId: 5678,
  email: "alice@example.com",
  items: [
    { productId: "kb-01", name: "Keyboard", price: 75.00, qty: 1 },
    { productId: "ms-02", name: "Mouse", price: 25.00, qty: 2 }
  ],
  createdAt: "2026-07-08T10:30:00Z"
};

// Publish to the "orders" queue — returns immediately
await queue.publish("orders", message);

// Respond to the user RIGHT NOW — don't wait for email/PDF
return res.json({ status: "confirmed", orderId: 1234 });
// Consumer (a separate worker process) — process messages
queue.consume("orders", async (message) => {
  // This runs asynchronously, long after the user got their response

  // 1. Generate PDF invoice
  const pdf = await generateInvoicePDF(message.orderId, message.items);

  // 2. Send confirmation email
  await sendEmail(message.email, "Order confirmed", pdf);

  // 3. Update analytics
  await analytics.track("order_placed", {
    orderId: message.orderId,
    total: message.items.reduce((sum, i) => sum + i.price * i.qty, 0)
  });

  // Acknowledge — tell the queue "I finished this one, give me the next"
  message.ack();
});

Two things to notice: (1) the producer publishes and returns immediately — no waiting. (2) the consumer calls message.ack() when done. This is acknowledgment — it tells the queue the message was processed successfully. If the consumer crashes before acknowledging, the queue re-delivers the message to another consumer. This guarantees no messages are lost.

The two delivery models

1. Point-to-point (work queue)

One producer, one queue, multiple consumers. Each message is processed by exactly one consumer. This is the model above — if you have 3 email workers, a message goes to whichever worker is free. The others don’t need to see it.

Example: sending order confirmation emails. You have 5 worker processes. Each message (one order) is handled by one worker. The queue distributes work across all 5.

2. Publish/subscribe (pub/sub)

One producer, one message, multiple subscribers. Each subscriber gets its own copy of the message. This is used when multiple systems need to react to the same event.

Example: when an order is placed, the email service sends a confirmation, the inventory service decrements stock, AND the analytics service records the sale. Three independent consumers, each processing the same “order_placed” event.

The guarantees: delivery semantics

Message queues make promises about how messages are delivered. There are three levels, and each trades simplicity for reliability:

1. At-most-once

Messages may be lost but never duplicated. The producer sends and forgets — no acknowledgment, no retry. If the consumer crashes, the message is gone.

Use case: telemetry, metrics, log shipping. Losing a few data points doesn’t matter — the aggregate trend is still accurate.

2. At-least-once

Messages are never lost but may be duplicated. The consumer must ack() each message. If it crashes before acknowledging, the queue re-delivers. This is the most common model — but it means consumers must be idempotent (processing the same message twice must not cause side effects like double-charging).

Use case: order processing, email sending, webhooks. You don’t want to lose orders, but you might see duplicates — so you design consumers to handle them (e.g., check “has this orderId already been processed?“).

3. Exactly-once

Messages are delivered exactly one time — no loss, no duplication. This is the holy grail, and it’s expensive. True exactly-once requires distributed consensus (two-phase commit) or transactional outbox patterns. Most systems fake it with at-least-once + idempotency.

Use case: financial transactions. Kafka supports exactly-once via transactional producers (writes to multiple partitions atomically) + read-committed consumers (only see committed transactions). But this is effectively-once, not true exactly-once — it only works within Kafka’s ecosystem. Cross-system exactly-once (e.g., Kafka → PostgreSQL) still requires the transactional outbox pattern.

Dead letter queues: when messages can’t be processed

What happens if a message is poisonous — malformed data, a missing reference, a bug in the consumer? The consumer tries to process it, fails, and the queue re-delivers it. The consumer fails again. This loops forever — a poison pill that blocks the queue.

The fix: a dead letter queue (DLQ). After N failed delivery attempts (the “retry limit”), the queue moves the message to the DLQ instead of re-delivering it. The DLQ is a separate queue for messages that need human inspection. Engineers monitor the DLQ and fix the root cause.

// Consumer with retry limit + dead letter queue
queue.consume("orders", async (message) => {
  try {
    await processOrder(message);
    message.ack();
  } catch (err) {
    const attempts = message.metadata.attempts || 0;

    if (attempts < 3) {
      // Retry: re-queue with incremented attempt count
      message.nack({ requeue: true, attempts: attempts + 1 });
    } else {
      // Give up: send to dead letter queue for investigation
      await queue.publish("orders.DLQ", {
        originalMessage: message,
        error: err.message,
        failedAt: new Date().toISOString()
      });
      message.ack(); // remove from main queue
    }
  }
});

When to use a queue (and when NOT to)

Use a queue when:

  • Work takes longer than an HTTP timeout (>2 seconds)
  • Multiple systems need to react to the same event (pub/sub)
  • Workload is bursty — a sudden spike of orders should queue up, not crash the server
  • You need retries — if the email server is down, the message waits and retries later
  • You need to decouple producer and consumer — the app server doesn’t need to know about email/PDF/analytics

Do NOT use a queue when:

  • The user needs the result immediately (e.g., search results, login validation)
  • Work is fast (<1 second) — the overhead of publishing + consuming is more than just doing it inline
  • You can’t tolerate eventual consistency — the user must see the result before proceeding
  • You need real-time latency guarantees (<100ms) — queue overhead adds 10-100ms+ depending on the broker

Backpressure: when the queue overflows

A queue buffers spikes — but what happens when producers consistently outpace consumers? The queue grows. And grows. Eventually it hits a memory limit or a disk limit. This is the backpressure problem, and there are three strategies to handle it:

  • Reject: when the queue is full, reject new messages (return 503 or 429 to the producer). The producer can retry later. This is how SQS and RabbitMQ work — the queue has a max length, and excess messages are dropped or dead-lettered.
  • Scale: auto-scale the number of consumers. More workers = faster drain. This is the cloud-native answer — Kubernetes HPA (Horizontal Pod Autoscaler) can spin up new consumer pods when the queue depth exceeds a threshold.
  • Throttle: push back on the producer. Tell it to slow down. This is the load-shedding approach — better to reject early than to queue unboundedly and crash the whole system.

The key insight: a queue absorbs spikes, not sustained overload. If producers consistently outpace consumers, no queue size is large enough — you need more consumers or fewer messages.

Real-world technologies

The three most common message queue technologies, each with different strengths:

TechnologyModelBest forTradeoff
RabbitMQTraditional broker (AMQP)Task queues, work distribution, complex routingMessages removed on ack — no replay. Lower throughput than Kafka.
Apache KafkaAppend-only log (partitioned)Event streaming, analytics, high-throughput pipelinesMessages persist (replayable), but more complex to operate. No per-message ack.
Amazon SQSManaged service (AWS)Simple work queues, no infrastructure to manageVendor lock-in. Limited routing compared to RabbitMQ. No pub/sub (use SNS for that).

RabbitMQ removes messages when consumed — it’s a to-do list. Kafka keeps messages — it’s a ledger. Choose based on whether you need to replay history.

Connection to Day 7 (load balancing), Day 8 (connection pooling), and Day 9 (rate limiting)

Load balancing, rate limiting, and message queues all sit between clients and your servers, but they solve different problems:

  • Load balancer (Day 7): Where does work go? Distributes synchronous requests across servers. The caller waits for the response.
  • Rate limiter (Day 9): How much gets through? Caps requests per client. Excess traffic gets 429.
  • Message queue (Day 14): When does work happen? Decouples producers from consumers. The caller doesn’t wait.

And the hidden fourth layer: connection pooling (Day 8). Each consumer worker needs its own database connection to do its work. Without pooling, 20 workers × 10 concurrent messages = 200 database connections — you’d hit max_connections instantly. The pool lets many workers share a small set of real connections, just as it did for the app servers on Day 8.

In a real system, these compose: a client’s request hits the rate limiter → passes through → reaches the load balancer → distributed to an app server → the app server publishes to a message queue → the user gets a fast response → a worker consumes the message later → the worker borrows a connection from the pool → runs queries → returns the connection.

The transfer question

From Day 7 → Day 9 → Day 14: You have an e-commerce API. During a flash sale, 50,000 users place orders in one minute. The order processing takes 8 seconds per order (charge card, send email, update inventory, generate PDF). How do load balancing, rate limiting, and message queues work together to keep the system alive?

Answer (click to reveal)

Rate limiter (Day 9): sits at the edge, caps each client to a reasonable number of requests (e.g., 5 orders/min). This blocks scrapers and bots. Legitimate traffic passes through.

Load balancer (Day 7): distributes the surviving requests across multiple app servers. No single server gets all 50,000 connections.

Message queue (Day 14): the app server does the fast synchronous part (validate cart, charge card) in ~3 seconds, then publishes “process order #1234” to the queue. The user gets “Order confirmed” immediately. The slow work (email, PDF, analytics) is consumed by worker processes at their own pace.

Without the queue, each request holds a connection for 8 seconds. 50,000 requests × 8 seconds = 400,000 connection-seconds. Even with load balancing, you’d need hundreds of app servers. With the queue, the app server handles each request in 3 seconds, and the slow work is buffered — workers process the backlog gradually, the queue absorbs the spike, and no messages are lost.

Check your understanding

1. What is the key difference between a synchronous HTTP call and publishing to a message queue?

2. Why must consumers be idempotent when using at-least-once delivery?

3. What problem does a dead letter queue (DLQ) solve?