Skip to main content

🐇 RabbitMQ Basics & Setup

Last lesson was theory; this one is hands on the keyboard. You'll start a real RabbitMQ broker with one Docker command, open its management dashboard, and write a Node.js publisher and consumer that pass messages back and forth — then graduate to exchanges that route messages intelligently.

Week 14 · Tuesday: Message Queues · Lecture 2

🎯 Learning Objectives

By the end of this lesson, you will be able to:

  • Run RabbitMQ locally with Docker and sign in to the management UI
  • Explain RabbitMQ's model: connection, channel, exchange, queue, binding, routing key
  • Publish and consume messages from Node.js with amqplib
  • Use durable queues, persistent messages, and manual acks for reliable delivery
  • Tune fairness with prefetch so slow workers don't hoard messages
  • Choose among direct, topic, and fanout exchanges and wire up bindings

Estimated Time: 70 minutes

Practice: Build a work queue with competing consumers and a severity-routed logging system.

In This Lesson

What Is RabbitMQ?

RabbitMQ is an open-source message broker that has been quietly running production systems since 2007. It implements AMQP 0-9-1 (the Advanced Message Queuing Protocol), an open standard, so clients exist for virtually every language. It's written in Erlang — a language built for concurrent, distributed, fault-tolerant systems — which is a big part of why RabbitMQ is so dependable.

What sets RabbitMQ apart is its smart routing. Producers don't send messages straight to queues; they send them to an exchange, and the exchange decides — by rules you define — which queues receive a copy. That indirection is what lets one publish reach one worker, or ten services, or none, without the producer changing a line.

graph LR A["JavaScript client"] -->|AMQP| B["RabbitMQ Broker"] C["Python client"] -->|AMQP| B D["Go client"] -->|AMQP| B B -->|delivers to| G["Consumers"]

💡 Why start with RabbitMQ?

It's the friendliest first broker: rich routing you can see in a web dashboard, a mature Node client, and clear acknowledgement semantics that teach you the reliability concepts you'll carry to every other messaging system.

Setup with Docker

The fastest, cleanest way to get a broker for development is Docker. This one command pulls RabbitMQ with the management plugin and runs it:

# Port 5672 = AMQP (your apps connect here)
# Port 15672 = the web management dashboard
docker run -d --name rabbitmq \
  -p 5672:5672 -p 15672:15672 \
  rabbitmq:3-management

Give it a few seconds to boot, then open http://localhost:15672 and sign in:

FieldValue
Usernameguest
Passwordguest

⚠️ The guest account is localhost-only

By design, guest/guest only works from localhost. For any real deployment, create a dedicated user with a strong password and the minimum permissions it needs. Never ship the default credentials.

Prefer a native install? Grab Erlang and the server from rabbitmq.com/download, then enable the dashboard with rabbitmq-plugins enable rabbitmq_management. On macOS, brew install rabbitmq works too. For learning, Docker keeps your machine clean.

The management dashboard

The UI at port 15672 is your window into the broker. You'll use it constantly to watch Queues (depth, message rates), inspect Exchanges and their bindings, view live Connections and Channels, and manage users under Admin. When something isn't working, this dashboard is the first place to look.

The RabbitMQ Model

Six terms unlock everything else. Read them once here, and the code below becomes obvious.

graph LR P["Producer"] -->|publishes to| E["Exchange"] E -->|binding: orders| Q1["Orders Queue"] E -->|binding: emails| Q2["Emails Queue"] Q1 --> C1["Consumer 1"] Q2 --> C2["Consumer 2"]
TermRole
ConnectionA single TCP connection between your app and the broker. Expensive — create one per process.
ChannelA lightweight virtual connection inside a connection. Do your real work on channels — one per thread/task.
ExchangeReceives every published message and decides which queues get a copy, using its type and bindings.
QueueThe buffer that actually holds messages until a consumer takes them.
BindingA rule linking an exchange to a queue, often keyed by a pattern.
Routing keyA label the producer stamps on a message; the exchange matches it against bindings.

📮 Analogy: the sorting office

The exchange is the mail-sorting facility, the routing key is the address on the envelope, the bindings are the sorting rules ("everything for zip 021xx goes in this bag"), and each queue is a delivery bag for one route. The producer just writes an address and drops the letter — the sorter does the rest.

💡 The "default exchange" shortcut

When you call sendToQueue(name, ...) you're using RabbitMQ's nameless default exchange, which routes to the queue whose name equals the routing key. It's a convenience for simple work queues — but understanding that a real exchange is involved is what lets you graduate to routing.

Your First Queue

Install the client, then write a publisher and a consumer that talk through a single queue.

npm install amqplib

Publisher — publisher.js

const amqp = require('amqplib');

async function publishMessage() {
  // 1. Open a connection (one per process) and a channel (per task).
  const connection = await amqp.connect('amqp://localhost');
  const channel = await connection.createChannel();

  // 2. Declare the queue. assertQueue is idempotent: it creates the queue
  //    if missing, or does nothing if it already exists.
  const queue = 'tasks';
  await channel.assertQueue(queue, { durable: true }); // survive broker restart

  // 3. Build a message. Messages travel as raw bytes, so serialize to a Buffer.
  const message = {
    id: Math.floor(Math.random() * 1000),
    task: 'Process data',
    timestamp: new Date().toISOString(),
  };

  channel.sendToQueue(queue, Buffer.from(JSON.stringify(message)), {
    persistent: true, // write to disk so it isn't lost on restart
  });

  console.log(`[x] Sent: ${JSON.stringify(message)}`);

  // 4. Give the broker a moment to receive, then close cleanly.
  await channel.close();
  await connection.close();
}

publishMessage().catch(console.error);

Consumer — consumer.js

const amqp = require('amqplib');

async function consumeMessages() {
  const connection = await amqp.connect('amqp://localhost');
  const channel = await connection.createChannel();

  const queue = 'tasks';
  await channel.assertQueue(queue, { durable: true }); // must match the publisher

  // Only hand this consumer one unacked message at a time (fair dispatch).
  channel.prefetch(1);

  console.log('[*] Waiting for messages. Press CTRL+C to exit.');

  channel.consume(queue, async (msg) => {
    if (msg === null) return; // queue was deleted / cancelled

    const content = JSON.parse(msg.content.toString());
    console.log(`[x] Received: ${JSON.stringify(content)}`);

    // Simulate real work, then acknowledge so the broker deletes it.
    await new Promise((r) => setTimeout(r, 500));
    console.log(`[x] Done task ${content.id}`);
    channel.ack(msg); // manual ack — the message is gone only after this
  }, { noAck: false }); // manual acknowledgement mode
}

consumeMessages().catch(console.error);

Running it

Terminal 1:  node consumer.js
             [*] Waiting for messages. Press CTRL+C to exit.

Terminal 2:  node publisher.js
             [x] Sent: {"id":742,"task":"Process data",...}

Terminal 1:  [x] Received: {"id":742,...}
             [x] Done task 742

Start the consumer first, then run the publisher. Watch the tasks queue in the management UI — you'll see the message count tick up and back down in real time.

Reliability: Durability, Persistence, Acks

By default, a message can vanish in three ways: the broker restarts, the process crashes mid-work, or the message is acked before the work finishes. Three settings close those gaps — and you need all three for end-to-end safety.

SettingWhereProtects against
durable: trueassertQueueLosing the queue itself on broker restart
persistent: truesendToQueue / publishLosing the message on broker restart
Manual ackconsume + channel.ack()Losing work when a consumer crashes mid-processing

⚠️ Durable + persistent go together

A durable queue full of non-persistent messages still loses those messages on restart — the queue survives but its contents don't. Likewise, persistent messages in a non-durable queue vanish with the queue. Set both, or you've only half-solved the problem.

ack vs nack

channel.consume(queue, async (msg) => {
  try {
    await doWork(JSON.parse(msg.content.toString()));
    channel.ack(msg);                    // success → broker deletes it
  } catch (err) {
    console.error('Processing failed:', err);
    // nack(msg, allUpTo, requeue). Set requeue=false to send it onward
    // to a dead-letter queue instead of looping forever.
    channel.nack(msg, false, false);
  }
}, { noAck: false });

✅ Ack after the work, never before

The golden rule: acknowledge only once the side effects have committed. If you ack first and then crash, the broker has already deleted the message and the work is silently lost. Ack last, and a crash simply causes redelivery.

Fair Dispatch with Prefetch

By default RabbitMQ round-robins messages to consumers the instant they connect — even if one worker is already buried under slow tasks. The result: a fast, idle worker sits waiting while a slow worker's inbox overflows. prefetch fixes this.

// "Don't give me a new message until I've acked my current one."
channel.prefetch(1);
graph LR Q["Task Queue"] -->|one at a time| W1["Worker 1
busy"] Q -->|next free worker| W2["Worker 2
idle"] Q -->|next free worker| W3["Worker 3
idle"]

With prefetch(1), the broker holds back new messages from a worker until it acks the one it has. Work naturally flows to whoever is free, and no single consumer hoards a backlog. For high-throughput pipelines a small batch (say prefetch(10)) can improve throughput — measure and tune for your workload.

💡 Prefetch is per-consumer fairness, not a global limit

It caps how many unacknowledged messages one consumer may hold at once. Combined with competing consumers on the same queue, it gives you smooth, self-balancing load distribution for free.

Exchanges & Routing

Work queues get you far, but the real power is in exchanges. Three types cover nearly everything.

Fanout — broadcast to all

Ignores routing keys entirely; every bound queue gets a copy. Perfect for pub/sub.

graph LR P["Publisher"] --> E["Fanout Exchange"] E -->|copy| Q1["Queue 1"] E -->|copy| Q2["Queue 2"] E -->|copy| Q3["Queue 3"]
// Publisher — broadcast a log line to everyone listening.
const ex = 'logs';
await channel.assertExchange(ex, 'fanout', { durable: false });
channel.publish(ex, '', Buffer.from(JSON.stringify({ msg: 'System update done' })));
//                   ^^ empty routing key — fanout ignores it anyway

// Consumer — each gets its own temporary, auto-deleting queue.
await channel.assertExchange(ex, 'fanout', { durable: false });
const { queue } = await channel.assertQueue('', { exclusive: true });
await channel.bindQueue(queue, ex, '');
channel.consume(queue, (msg) => {
  console.log('[x] Broadcast:', msg.content.toString());
  channel.ack(msg);
});

Direct — exact routing-key match

Delivers to queues whose binding key exactly equals the message's routing key. The classic use is severity-based log routing.

graph LR P["Publisher"] -->|key: error| E["Direct Exchange"] E -->|binding: error| Q1["Errors Queue"] E -->|binding: info| Q2["Info Queue"]
// Publisher — the severity IS the routing key.
const ex = 'logs_direct';
await channel.assertExchange(ex, 'direct', { durable: false });
const severity = process.argv[2] || 'info'; // 'info' | 'warning' | 'error'
channel.publish(ex, severity, Buffer.from(`a ${severity} message`));

// Consumer — subscribe only to the severities you care about.
await channel.assertExchange(ex, 'direct', { durable: false });
const { queue } = await channel.assertQueue('', { exclusive: true });
for (const sev of process.argv.slice(2)) {
  await channel.bindQueue(queue, ex, sev); // bind once per severity
}
channel.consume(queue, (msg) => {
  console.log(`[x] ${msg.fields.routingKey}: ${msg.content.toString()}`);
  channel.ack(msg);
});

Topic — wildcard pattern match

Routing keys are dot-delimited words; bindings use two wildcards: * matches exactly one word, # matches zero or more.

graph LR P["Publisher"] -->|key: usa.news.sports| E["Topic Exchange"] E -->|pattern: usa.#| Q1["USA Queue"] E -->|pattern: star.news.star| Q2["News Queue"] E -->|pattern: hash.sports| Q3["Sports Queue"]
Binding patternMatches key usa.news.sports?
usa.#✅ (# = anything after usa.)
*.news.*✅ (three words, middle is news)
#.sports✅ (ends in sports)
usa.*❌ (* is one word; key has three)

💡 There's a fourth: headers

A headers exchange routes on message header attributes instead of a routing-key string — handy when several attributes decide the destination. In practice, direct, topic, and fanout handle the overwhelming majority of designs.

Practice & Quiz

🏋️ Exercise 1: A work queue with competing consumers

Goal: Send ten numbered tasks to a durable queue and run two consumers. Confirm the tasks split between them, and that killing one consumer mid-task redelivers its unacked message to the other.

💡 Hint

Use prefetch(1) and manual acks on the consumer. Add a variable setTimeout per task so the split is visible. Start two consumers, then the publisher.

✅ Solution
// producer.js — send 10 tasks with varied "work" size.
const amqp = require('amqplib');
(async () => {
  const conn = await amqp.connect('amqp://localhost');
  const ch = await conn.createChannel();
  await ch.assertQueue('work', { durable: true });
  for (let i = 1; i <= 10; i++) {
    const dots = '.'.repeat(i % 4);        // 0-3 "seconds" of work
    ch.sendToQueue('work', Buffer.from(`task ${i}${dots}`), { persistent: true });
    console.log(`[x] Sent task ${i}`);
  }
  await ch.close(); await conn.close();
})();

// worker.js — run this in TWO terminals.
const amqp = require('amqplib');
(async () => {
  const conn = await amqp.connect('amqp://localhost');
  const ch = await conn.createChannel();
  await ch.assertQueue('work', { durable: true });
  ch.prefetch(1);                          // fair dispatch
  ch.consume('work', async (msg) => {
    const body = msg.content.toString();
    const secs = (body.match(/\./g) || []).length;
    console.log(`[x] ${body}`);
    await new Promise((r) => setTimeout(r, secs * 1000));
    console.log(`[x] done: ${body}`);
    ch.ack(msg);                           // ack only after the work
  }, { noAck: false });
})();
// Kill one worker mid-task (Ctrl+C): its unacked message reappears
// on the other worker — proof that manual acks make work safe.

🏋️ Exercise 2: Route logs by severity

Goal: With a direct exchange, run one consumer bound to error only and another bound to info, warning, and error. Publish messages and confirm each consumer sees only what it subscribed to.

💡 Hint

Reuse the direct-exchange snippets above. The routing key on publish must exactly match a binding key for the message to arrive.

✅ Solution
// Consumer A (errors only):   node consumer.js error
// Consumer B (everything):     node consumer.js info warning error
// Publish:                     node publisher.js error
//                              node publisher.js info
//
// Result: A prints only the 'error' line; B prints both.
// Each severity you pass on the consumer becomes a bindQueue call,
// so B has three bindings and A has one.

🎯 Quick Quiz

Question 1: What does channel.prefetch(1) accomplish?

Question 2: Which exchange type ignores the routing key and copies every message to all bound queues?

Question 3: You set durable: true on a queue but forget persistent: true on messages. After a broker restart, what survives?

Best Practices & Pitfalls

✅ Do

  • Open one connection per process and many channels on it — channels are cheap, connections are not
  • Declare queues and exchanges with assert* on startup so infrastructure is self-creating
  • Use durable queues + persistent messages + manual acks for anything you can't afford to lose
  • Set prefetch to get fair dispatch across competing consumers
  • Configure a dead-letter exchange and reconnect logic before going to production

❌ Don't

  • Open a new connection per message — it will crush the broker under load
  • Use noAck: true for important work — a crash then loses the message
  • Ship the default guest/guest user to production
  • Send huge payloads; store big data externally and pass a reference
  • Requeue a failing message endlessly with no dead-letter escape hatch

⚠️ Handle connection drops

Networks fail. A production client listens for the connection's error and close events and reconnects with backoff, re-declaring its channels and consumers. amqplib doesn't auto-reconnect for you — libraries like amqp-connection-manager add it, or you write a small wrapper.

Summary

🎉 Key Takeaways

  • One Docker command gives you a broker plus a live dashboard at port 15672
  • Work happens on channels inside a single connection; producers publish to exchanges, which route to queues via bindings
  • assertQueue is idempotent; sendToQueue/consume move bytes as Buffers
  • Reliability needs all three: durable queue, persistent message, manual ack after the work
  • prefetch delivers fair dispatch across competing consumers
  • Fanout broadcasts, direct matches keys exactly, topic matches key patterns with * and #

📚 Additional Resources

🚀 What's Next?

You can now move messages reliably and route them with intent. In the final lesson of this trio, Event-Driven Architecture, you'll stitch several services together with topic exchanges so a single order ripples through payment, inventory, and notifications — no service calling another directly.

🎉 You shipped real messages!

Publisher, consumer, acks, prefetch, and three exchange types — the everyday toolkit of production messaging.