Message Broker

RabbitMQ

RabbitMQ is an AMQP message broker that routes messages through exchanges into queues. Acknowledgements, prefetch and dead-lettering make it a precise tool for work that must not be lost.

intermediate15 min readUpdated Sep 16, 2026
publish.ts
ts
// publish.ts
import amqp from "amqplib";

const conn = await amqp.connect("amqp://localhost");
const ch = await conn.createChannel();

await ch.assertExchange("orders", "topic", { durable: true });
await ch.assertQueue("orders.created", { durable: true });
await ch.bindQueue("orders.created", "orders", "order.created.*");

ch.publish(
  "orders",
  "order.created.eu",
  Buffer.from(JSON.stringify({ id: 42 })),
  { persistent: true, contentType: "application/json" },
);

await ch.close();
await conn.close();
Released
2007
Protocol
AMQP 0-9-1
Written in
Erlang
Core idea
Exchanges and bindings
Delivery
At-least-once with manual ack
Best for
Routing, work queues, RPC

Why it matters

Why teams reach for RabbitMQ

Routing that is a feature

Direct, fanout, topic and headers exchanges route one message to many queues by key, pattern or attribute instead of by hand.

Acknowledgements and redelivery

A consumer acks or nacks each message. An unacked message is redelivered when the connection drops, so work is not silently lost.

Dead-lettering built in

Rejected or expired messages can be routed to a dead-letter exchange, which makes retry queues and poison-message handling first-class.

The big picture

The AMQP mental model

A producer never publishes to a queue. It publishes to an exchange, and bindings decide which queues receive a copy.

Exchange

Route

Producers publish to an exchange with a routing key. The exchange decides which queues get a copy based on its type and bindings.

Binding

Match

A binding is a rule that connects an exchange to a queue. Topic bindings match wildcards, direct bindings match exactly.

Consumer

Acknowledge

A consumer receives deliveries with a prefetch limit, processes each one and acks, nacks or dead-letters it.

At a glance

The pieces you will configure

Queue

An ordered buffer that holds messages until a consumer takes them.

Exchange

The routing front door: direct, fanout, topic or headers.

Binding

A rule connecting an exchange to a queue, often with a routing key.

Ack and nack

Manual acknowledgement tells the broker a message is safe to forget.

Prefetch

basic.qos caps how many unacked messages a consumer may hold.

Dead letter

Rejected, expired or overflowed messages route to a DLX.

Flow

From publish to ack

A message is only removed from its queue when a consumer acknowledges it. Everything before that is the broker's responsibility.

  1. 1

    Publish to an exchange

    The producer sends a message with a routing key and a delivery mode. It does not know or care which queues exist.

  2. 2

    Route by binding

    The exchange matches the routing key against its bindings and copies the message into every matching queue.

  3. 3

    Deliver with prefetch

    A consumer receives up to its prefetch limit of unacknowledged messages, then the broker pauses delivery until one is acked.

  4. 4

    Process and ack

    The handler finishes the work and calls ack. The broker then removes the message from the queue for good.

  5. 5

    Nack or dead-letter

    On failure the consumer nacks with requeue, or rejects the message so it is routed to a dead-letter exchange.

  6. 6

    Redeliver or park

    The broker redelivers an unacked message after a reconnect, or parks a rejected one in a dead-letter queue for inspection.

A short history

From AMQP bet to enterprise broker

  1. 2007

    RabbitMQ is created

    Rabbit Technologies builds an AMQP broker in Erlang and open-sources it, betting on a standard protocol.

    07
  2. 2010

    SpringSource acquires it

    The broker gains commercial backing and becomes the default messaging layer for many Java and Ruby stacks.

    10
  3. 2013

    Clusters and federation

    Clustering and federation mature, letting teams run brokers across data centres and trust boundaries.

    13
  4. 2018

    Streams arrive

    RabbitMQ 3.8 adds a log-like stream type, borrowing the replay model from Kafka.

    18
  5. 2020

    Quorum queues

    Raft-based quorum queues become the recommended replication strategy over classic mirrored queues.

    20
  6. 2023

    AMQP 1.0 support

    RabbitMQ 3.12 and later support AMQP 1.0 alongside 0-9-1, widening the client ecosystem.

    23

The complete guide

RabbitMQ: Everything you need to know

What is RabbitMQ?

RabbitMQ is an open-source message broker that speaks AMQP, the Advanced Message Queuing Protocol. Producers publish messages to it, consumers receive them, and the broker is responsible for holding each message until someone has acknowledged it.

The phrase that best describes its design is a smart broker with dumb consumers. RabbitMQ knows about exchanges, routing rules, acknowledgements, retries and dead letters. A consumer is a small program that reads a message, does one thing and says it is done. That division of labour is why RabbitMQ fits systems where routing and delivery guarantees matter more than raw throughput.

It was written in Erlang in 2007, which is why it is unusually good at handling many concurrent connections and why it has a reputation for stability. It runs a full protocol rather than a minimal queue API, and that protocol is the source of both its power and its learning curve.

The AMQP model

The single most important idea in RabbitMQ is that a producer never publishes to a queue. It publishes to an exchange, and the exchange decides which queues receive a copy.

producer -> exchange --binding--> queue -> consumer
                     \--binding--> queue -> consumer

Five pieces make up the model:

  • A producer opens a channel and calls basic.publish with an exchange name, a routing key and a body.
  • An exchange receives every published message and routes it. It does not store anything unless it is bound to a queue.
  • A binding is a rule connecting an exchange to a queue. It can carry a routing key or a pattern, and one exchange can bind to many queues.
  • A queue stores messages in order until a consumer takes them.
  • A consumer subscribes to a queue, receives deliveries, and acknowledges them.

This indirection is the whole point. A producer that publishes order.created.eu does not know whether one service, five services or nobody is listening. New consumers are added by binding a new queue, without touching the producer.

All work happens on a channel, which is a lightweight virtual connection multiplexed over a single TCP connection. Channels are not thread-safe, so the common pattern is one channel per task or per consumer.

import amqp from "amqplib";

const conn = await amqp.connect(process.env.AMQP_URL!);
const ch = await conn.createChannel();

Exchange types and routing

An exchange’s type determines how it matches a routing key to its bindings. There are four, and each has a clear use.

A direct exchange routes to queues whose binding key equals the routing key exactly. Use it to send a message to one specific queue, or to a small set that all share a key.

await ch.assertExchange("logs", "direct", { durable: true });
await ch.bindQueue("logs.errors", "logs", "error");
await ch.publish("logs", "error", body);

A fanout exchange ignores the routing key entirely and copies the message to every bound queue. It is pub/sub in its purest form: one publish, many independent consumers.

await ch.assertExchange("events", "fanout", { durable: true });
await ch.bindQueue("search-indexer", "events", "");
await ch.bindQueue("email-notifier", "events", "");

A topic exchange matches the routing key against a pattern. Keys are dot-separated words. * matches exactly one word and # matches zero or more, which makes topic exchanges the most flexible of the four.

await ch.assertExchange("orders", "topic", { durable: true });

await ch.bindQueue("eu-orders", "orders", "order.*.eu");
await ch.bindQueue("all-orders", "orders", "order.#");
await ch.publish("orders", "order.created.eu", body);

A headers exchange ignores the routing key and matches on message header attributes instead. It is rarely the right choice, because topic exchanges are easier to read and reason about, but it is useful when routing depends on several independent attributes.

If you remember one rule, make it this: choose the exchange type by the question you are asking. “Which exact queue?” is direct. “Everyone?” is fanout. “Which family of events?” is topic.

Acknowledgements, nack and prefetch

RabbitMQ’s delivery guarantee is built on acknowledgement. When a consumer receives a message, the broker marks it unacked but keeps it. Only when the consumer calls ack is the message removed. If the connection drops before that, the broker redelivers.

await ch.consume("orders.created", async (msg) => {
  if (!msg) return;

  try {
    await handleOrder(JSON.parse(msg.content.toString()));
    ch.ack(msg);
  } catch (err) {
    ch.nack(msg, false, false); // requeue: false, dead-letter instead
  }
});

nack (or the older reject) takes a requeue flag. Requeueing puts the message back at the head of the queue, which is right for a transient failure and wrong for a poison message that will fail forever. Sending it to a dead-letter exchange instead is the usual answer.

Prefetch, set with basic.qos, caps how many unacknowledged messages a consumer holds at once. Without it, the broker pushes messages as fast as it can and a slow consumer buffers thousands of them in memory.

await ch.prefetch(20);

Set prefetch to a small multiple of your real concurrency. Too high and one consumer hogs the queue; too low and the consumer sits idle waiting for the next round trip.

Durability and delivery guarantees

A message survives a broker restart only if three things are true, and it is easy to get one wrong.

  • The queue must be declared durable: true.
  • The message must be published with persistent: true.
  • The exchange should be durable: true as well.
await ch.assertExchange("orders", "topic", { durable: true });
await ch.assertQueue("orders.created", { durable: true });

ch.publish("orders", "order.created.eu", body, { persistent: true });

Durability is about surviving a restart, not about guaranteeing delivery. For that, enable publisher confirms. Without confirms, publish is fire-and-forget: if the broker dies before writing the message, the producer never knows.

const ch = await conn.createConfirmChannel();

ch.publish("orders", "order.created.eu", body, { persistent: true }, (err) => {
  if (err) console.error("broker did not confirm", err);
  else console.log("message is safely queued");
});

Even with confirms and durable queues, delivery is at-least-once. A consumer can crash after doing the work but before acking, and the broker will redeliver. Exactly-once across a network is effectively impossible, so RabbitMQ makes the trade explicit and asks you to make handlers idempotent.

Dead-letter exchanges and retry queues

A dead-letter exchange (DLX) is an ordinary exchange that receives messages which a queue rejects, expires or drops. It is the mechanism behind retry queues and poison-message handling.

A message is dead-lettered when one of these happens:

  • The consumer nacks or rejects it with requeue: false.
  • Its per-message or per-queue TTL expires.
  • The queue exceeds its length limit and drops the oldest message.

You configure the DLX on the queue that holds the work, not on the consumer.

await ch.assertQueue("orders.created", {
  durable: true,
  deadLetterExchange: "orders.dlx",
  deadLetterRoutingKey: "failed",
});

The classic delayed retry pattern uses a second queue with a TTL and its own DLX pointing back at the work exchange. A failed message is dead-lettered into the retry queue, sits there for the TTL, expires, and is dead-lettered back to be processed again. That produces a retry with a delay and no timer in your code.

await ch.assertExchange("orders.dlx", "direct", { durable: true });

await ch.assertQueue("orders.retry", {
  durable: true,
  messageTtl: 30_000,
  deadLetterExchange: "orders",
  deadLetterRoutingKey: "order.created.retry",
});

await ch.bindQueue("orders.retry", "orders.dlx", "retry");

A message that keeps failing will loop. Track a retry count in the message headers and, after a limit, route it to a permanent failed queue that nobody consumes automatically. Treat that queue as an operational surface: alert when it grows and build a replay path.

Message TTL and queue limits

Time-to-live controls how long a message may wait. Set it per queue, per message, or both.

await ch.assertQueue("verification", {
  durable: true,
  messageTtl: 600_000,          // 10 minutes for every message
  maxLength: 10_000,            // keep at most 10k messages
  overflow: "reject-publish",   // backpressure instead of dropping
});

Per-message TTL is set when publishing and is often used for values that differ by message.

ch.publish("orders", key, body, {
  expiration: "30000", // milliseconds, as a string
});

Queue length limits are backpressure. maxLength bounds memory, and overflow decides what happens when the limit is reached: drop-head silently discards the oldest message, while reject-publish refuses new publishes so the producer feels the pressure. For a queue that must not lose work, reject-publish is the safer default and an alerting signal.

Work queues vs pub/sub

The same broker covers two very different shapes of communication, and mixing them up causes bugs.

A work queue distributes each message to exactly one consumer. Many workers compete on the same queue, and the broker round-robins deliveries. Add a prefetch and acknowledgements and you have a scalable, fault-tolerant task queue.

await ch.assertQueue("jobs.thumbnails", { durable: true });
await ch.prefetch(5);

Pub/sub delivers each message to every interested consumer. Each subscriber gets its own queue bound to the same exchange, so a slow or offline subscriber never steals a message from the others.

await ch.assertExchange("events", "fanout", { durable: true });

await ch.assertQueue("events.billing", { durable: true });
await ch.bindQueue("events.billing", "events", "");

await ch.assertQueue("events.analytics", { durable: true });
await ch.bindQueue("events.analytics", "events", "");

The rule of thumb: one queue shared by many consumers is a work queue; one queue per consumer is pub/sub. A topic exchange lets you have both at once, with some consumers sharing a queue and others owning their own.

The RPC pattern

RabbitMQ can also do request/response. The client publishes a request with a replyTo queue and a correlationId, and the server publishes the response to that queue with the same id.

import { randomUUID } from "node:crypto";

const { queue } = await ch.assertQueue("", { exclusive: true });
const correlationId = randomUUID();

ch.consume(queue, (msg) => {
  if (msg?.properties.correlationId === correlationId) {
    console.log("reply", JSON.parse(msg.content.toString()));
  }
}, { noAck: true });

ch.publish("rpc.inventory", "check", Buffer.from("{}"), {
  replyTo: queue,
  correlationId,
});

The assertQueue("") creates a temporary, exclusive, auto-deleting queue just for this client. RPC over a queue is useful when the caller genuinely needs an answer, but it reintroduces synchronous coupling and a timeout problem. For most systems, an event plus a callback event is easier to operate than RPC, and a plain HTTP call is easier still when the dependency is available.

Clustering and quorum queues

A single node is a single point of failure, so production RabbitMQ runs as a cluster. Queues can be replicated across nodes, and clients reconnect to another node when one fails.

The modern replication strategy is the quorum queue, built on the Raft consensus algorithm. A quorum queue has a leader and followers, and a write is confirmed only once a majority has it. That makes it safe under the network partitions that caused classic mirrored queues to lose data, and it is the default choice for durable queues today.

await ch.assertQueue("orders.created", {
  durable: true,
  arguments: { "x-queue-type": "quorum" },
});

Quorum queues prefer a small, odd number of replicas, typically three or five. They are heavier than classic queues, so use them for data you cannot lose and keep transient queues classic. For multi-region topologies, federation and the shovel plugin move messages between independent brokers rather than stretching one cluster across a slow link.

The management UI and monitoring

Every RabbitMQ node ships with a management plugin that serves a web UI and an HTTP API. It shows exchanges, queues, bindings, connections and channels, and it lets you publish a test message or replay one from a queue.

rabbitmq-plugins enable rabbitmq_management

curl -u guest:guest http://localhost:15672/api/queues/%2F/orders.created

Four numbers matter most on a dashboard.

  • Queue depth — messages ready. A rising depth means consumers cannot keep up.
  • Unacked count — messages delivered but not acknowledged. A number that only grows means consumers are stuck.
  • Publish and deliver rates — the shape of the traffic, and whether consumers keep pace.
  • Redelivery rate — a rising redelivery count points at crashes or repeated failures.

RabbitMQ also emits Prometheus metrics, so queue depth and consumer utilisation belong on the same dashboard as the rest of your services. Alert on depth and oldest-message age, not on individual failures, which are expected.

Connections, channels and recovery

A connection is expensive; a channel is cheap. Open one connection per process, then create a channel per producer, per consumer or per unit of work. A channel is not thread-safe, so sharing one across concurrent handlers causes interleaved frames and confusing errors.

import amqp from "amqplib";

let conn: amqp.Connection;
let ch: amqp.Channel;

async function connect() {
  conn = await amqp.connect(process.env.AMQP_URL!);

  conn.on("error", (err) => console.error("connection error", err));
  conn.on("close", () => setTimeout(connect, 5_000));

  ch = await conn.createChannel();
  await ch.assertExchange("orders", "topic", { durable: true });
}

Handle reconnection deliberately. amqplib does not reconnect for you, so listen for close and rebuild the connection, channel and every consumer. Because a dropped connection leaves in-flight messages unacked, the broker redelivers them, which is the behaviour you want. This is another place where at-least-once delivery shows up: a reconnect can replay work, so handlers must be idempotent.

Message properties and priorities

Every published message can carry properties alongside its body. They travel with the message and are visible to consumers, which makes them a lightweight place for metadata.

ch.publish("orders", "order.created.eu", body, {
  persistent: true,
  contentType: "application/json",
  messageId: order.id,
  correlationId: traceId,
  timestamp: Math.floor(Date.now() / 1000),
  type: "order.created",
  headers: { "x-retry-count": 0, "x-source": "checkout" },
});

contentType and messageId help consumers and tooling; correlationId ties a message to a trace; headers are where you keep application metadata such as a retry count. Do not put large data in headers, because the broker must index and display them.

RabbitMQ also supports priority queues, where higher-priority messages are delivered first. Declare the queue with a maximum priority and publish with a priority value.

await ch.assertQueue("jobs", {
  durable: true,
  maxPriority: 10,
});

ch.publish("", "jobs", body, { priority: 9 });

Priorities only reorder messages that are already waiting. If consumers keep the queue empty, priority does nothing, and a high-priority message that arrives after a low one still waits behind it. Use priority for a genuine business distinction, not as a general scheduling mechanism.

Choosing RabbitMQ vs Kafka

The two are often compared, but they solve different problems.

RabbitMQ is a smart broker for routing and work queues. Messages are tasks that are removed once acknowledged. Exchanges route by pattern, acknowledgements are per message, and dead-lettering is built in. It shines when a message must reach a specific set of queues, or when a unit of work must be retried and parked on failure.

Kafka is a durable log. Messages are appended to a partitioned, ordered log and retained for a configured time regardless of who reads them. Many consumer groups can read the same topic independently, and any consumer can rewind to an earlier offset. It shines at very high throughput and at event replay.

A useful heuristic: if you would be upset that a message was consumed and deleted, you want a log. If you care that a task was routed correctly and completed, you want a broker. Many systems use both — Kafka for the event stream, RabbitMQ for the work.

Best practices

  • Declare exchanges, queues and bindings idempotently at startup, and use durable: true for anything you cannot lose.
  • Publish persistent messages to durable queues, and use publisher confirms for work that must not vanish.
  • Always acknowledge manually, after the side effect succeeds, never before.
  • Set a prefetch limit so one consumer cannot buffer the whole queue.
  • Give every durable queue a dead-letter exchange, and build a replay path for it.
  • Implement delayed retries with a TTL retry queue rather than sleeping in the handler.
  • Make consumers idempotent, because delivery is at-least-once.
  • Bound queue length and choose reject-publish when dropping messages is unacceptable.
  • Use quorum queues for durable data and classic queues for transient data.
  • Close channels and connections on SIGTERM, and stop consuming before draining.
  • Watch ready, unacked, redelivery rate and oldest-message age.

Common mistakes

  • Publishing to a queue instead of an exchange, then wondering why bindings do nothing.
  • Auto-acking (noAck: true) and losing messages whenever a handler crashes.
  • Forgetting persistent: true, then losing messages on a broker restart.
  • Declaring a queue as durable but publishing non-persistent messages.
  • Setting prefetch too high and letting one consumer starve the rest.
  • Requeueing a poison message forever with nack(msg, false, true).
  • Building a dead-letter exchange with no consumer and never looking at it.
  • Using a fanout exchange where a topic exchange was needed, or the reverse.
  • Running a single broker in production and calling it highly available.
  • Mixing work-queue and pub/sub semantics on the same queue.
  • Blocking the event loop in a consumer, which stops heartbeats and triggers a false disconnect.

Where to go next

If you want the same reliable-work idea with less infrastructure, the Redis Queues guide covers BullMQ on top of Redis. For the broader discipline of moving work out of the request path, read Batch Processing. When your events are a durable log that many consumers replay rather than tasks that are routed and removed, the Kafka guide is the natural next step, and Node.js basics covers the runtime every amqplib consumer runs on.

In practice

Four RabbitMQ operations

Publish, consume, route by topic and dead-letter. These four cover the majority of real deployments.

src/publish.ts
import amqp from "amqplib";

export async function publishOrder(order: Order) {
  const conn = await amqp.connect(process.env.AMQP_URL!);
  const ch = await conn.createChannel();

  await ch.assertExchange("orders", "topic", { durable: true });

  ch.publish(
    "orders",
    `order.created.${order.region}`,
    Buffer.from(JSON.stringify(order)),
    { persistent: true, contentType: "application/json" },
  );

  await ch.close();
  await conn.close();
}

Manual ack vs auto-ack

Auto-ack tells the broker to forget a message the instant it is delivered. If the handler crashes, the work is gone. Ack only after the side effect succeeds.

Prefer
await ch.prefetch(20);

await ch.consume("orders.created", async (msg) => {
  if (!msg) return;
  try {
    await handleOrder(JSON.parse(msg.content.toString()));
    ch.ack(msg);
  } catch {
    ch.nack(msg, false, false);
  }
});
Avoid
// noAck: true means the broker deletes the message
// on delivery, before the handler has done anything.
await ch.consume("orders.created", async (msg) => {
  if (!msg) return;
  await handleOrder(JSON.parse(msg.content.toString()));
}, { noAck: true });

Topic exchange vs direct exchange

A direct exchange matches one routing key exactly. A topic exchange matches patterns, which is what you want when consumers subscribe to a family of events.

Prefer
await ch.assertExchange("events", "topic", { durable: true });

// One consumer gets every order event in every region.
await ch.bindQueue("audit", "events", "order.#");
await ch.publish("events", "order.created.eu", body);
Avoid
await ch.assertExchange("events", "direct", { durable: true });

// A direct binding only catches the exact key,
// so the audit consumer misses every other event.
await ch.bindQueue("audit", "events", "order.created.eu");
await ch.publish("events", "order.shipped.eu", body);

Trade-offs

Is RabbitMQ the right broker?

RabbitMQ is a smart broker for precise routing and reliable work queues. It is not a log, and it is not the lightest thing to operate.

Strengths

  • Routing is first-class

    Exchanges, bindings and routing keys route one message to many queues by pattern, without the producer knowing who consumes it.

  • Reliable delivery you can reason about

    Manual acknowledgements, publisher confirms, durable queues and persistent messages combine into at-least-once delivery with clear knobs.

  • Dead-lettering is built in

    Retry queues, poison-message parking and delayed retries are configured declaratively instead of coded by hand.

Trade-offs

  • Not a replayable log

    Once a message is acked it is gone. If consumers need to re-read history or replay from an offset, a partitioned log like Kafka is the better fit.

  • Heavier to operate

    Erlang runtime, cluster formation, quorum queues and policy management are real work. Managed offerings soften this but do not remove it.

  • Per-message overhead

    Acknowledgements and confirms add round trips. At extreme throughput a log-structured broker usually wins on raw cost per message.

FAQ

Frequently asked questions

Keep learning

Related topics from the roadmap.

$ start learning

Ready to start learning RabbitMQ?

Our interactive tutorial walks you through RabbitMQ step by step — with quizzes and real code you can run in the browser.