RabbitMQ doesn’t actually have a built-in "delayed message" feature; it’s a clever workaround using standard AMQP features.

Let’s see it in action. Imagine you want to send a message that should only be processed by a consumer 10 seconds from now.

# Producer side (using amqplib in Node.js)
async function sendDelayedMessage(messageBody, delayInMilliseconds) {
  const connection = await amqp.connect('amqp://localhost');
  const channel = await connection.createChannel();

  // Declare a direct exchange that will route messages to our delayed queue
  const exchangeName = 'delayed_exchange';
  await channel.assertExchange(exchangeName, 'direct', { durable: true });

  // Declare a queue that will hold our delayed messages
  // This queue has a TTL (Time-To-Live) set to the delay we want
  const queueName = `delayed_queue_${delayInMilliseconds}`;
  await channel.assertQueue(queueName, {
    durable: true,
    messageTtl: delayInMilliseconds, // The magic happens here!
    deadLetterExchange: 'direct_exchange', // Where to send expired messages
    deadLetterRoutingKey: 'processed_key' // The routing key for the dead letter exchange
  });

  // Bind the queue to the exchange
  await channel.bindQueue(queueName, exchangeName, queueName); // Routing key matches queue name

  // Publish the message to the delayed exchange with the queue name as the routing key
  channel.publish(exchangeName, queueName, Buffer.from(messageBody), {
    persistent: true
  });

  console.log(`Message sent to ${queueName} with delay ${delayInMilliseconds}ms`);
  await channel.close();
  await connection.close();
}

sendDelayedMessage('{"task": "process_report", "id": 123}', 10000); // 10 second delay
# Consumer side (using amqplib in Node.js)
async function consumeMessages() {
  const connection = await amqp.connect('amqp://localhost');
  const channel = await connection.createChannel();

  // Declare the direct exchange where expired messages will be sent
  const deadLetterExchangeName = 'direct_exchange';
  await channel.assertExchange(deadLetterExchangeName, 'direct', { durable: true });

  // Declare a queue to receive the "expired" messages
  const processedQueueName = 'processed_queue';
  await channel.assertQueue(processedQueueName, { durable: true });
  await channel.bindQueue(processedQueueName, deadLetterExchangeName, 'processed_key');

  console.log('Waiting for messages in processed_queue...');

  channel.consume(processedQueueName, (msg) => {
    if (msg) {
      console.log(`Received delayed message: ${msg.content.toString()}`);
      // Process the message here
      channel.ack(msg); // Acknowledge the message
    }
  }, {
    noAck: false
  });
}

consumeMessages();

The core problem this solves is the need for scheduled or deferred message processing without relying on external schedulers like cron jobs or dedicated task queues. RabbitMQ’s TTL (Time-To-Live) and Dead Lettering (DLX) mechanisms are repurposed here.

Here’s the mental model:

  1. Producer: Instead of sending a message directly to a processing queue, the producer sends it to a special "delayed exchange." The routing key used here is not a static topic, but rather the name of a specific queue that will hold messages for a particular delay.
  2. Delayed Exchange: This exchange is a simple direct exchange. It routes messages to queues based on the exact match of the routing key.
  3. Delayed Queue: This is the key. For each distinct delay duration you need (e.g., 5 seconds, 10 seconds, 1 minute), you create a separate queue. This queue is configured with:
    • messageTtl: This is the crucial part. It defines how long a message can live in this queue before it expires.
    • deadLetterExchange: A different exchange where expired messages are automatically routed.
    • deadLetterRoutingKey: The routing key to use when sending expired messages to the dead letter exchange.
  4. Dead Letter Exchange (DLX): This is another exchange (often a direct exchange) where messages are sent after their TTL expires in the delayed queue.
  5. Processing Queue: A final queue that is bound to the DLX. The deadLetterRoutingKey from the delayed queue is used as the binding key for this processing queue. When a message expires, it’s routed to the DLX, which then routes it to the processing queue.
  6. Consumer: Your consumer simply listens to the processing queue. When a message arrives, it means its TTL has expired, and it’s now ready for actual processing.

The surprising part is that you need a unique queue for each delay duration you want to support. If you want to send messages with 5-second, 10-second, and 60-second delays, you’ll need three separate queues, each with its own messageTtl and bound to the same DLX. The producer then picks the correct queue name for its routing key.

The next concept you’ll likely encounter is managing these dynamically created queues efficiently, especially if you have many different delay values or a high volume of delayed messages. You might also explore plugins like rabbitmq_delayed_message_exchange if you prefer a single, dedicated plugin for this rather than the TTL/DLX workaround.

Want structured learning?

Take the full Rabbitmq course →