Skip to content

Closing Message Queues — Complete Implementation Guide

DodaTech Updated 2026-06-28 6 min read

In this tutorial, you will learn about Closing Message Queues. We cover key concepts, practical examples, and best practices to help you master this topic.

Closing message queue connections during graceful shutdown ensures all in-flight messages are acknowledged, consumers stop cleanly, and no messages are lost or left unprocessed.

What You'll Learn

By the end of this tutorial, you will know how to close RabbitMQ channels, drain Kafka consumers, clean up Redis pub/sub connections, and ensure message acknowledgments are sent before shutdown.

Why It Matters

Message queues often hold critical business data. An ungraceful shutdown can cause message loss, duplicate processing, or unacknowledged messages that block the queue.

Real-World Use

DodaZIP's file processing pipeline uses RabbitMQ. During deployment, the consumer closes its channel, waits for all in-flight file conversions to complete, acknowledges each message, then exits.

Closing Message Queues Learning Path

flowchart LR
  A[Closing DB Pools] --> B[Closing Message Queues]
  B --> C[RabbitMQ]
  B --> D[Kafka]
  B --> E[Redis]
  B --> F{You Are Here}
  style F fill:#f90,color:#fff

RabbitMQ Channel Closing

RabbitMQ requires closing the channel first, which stops consuming but allows in-flight message processing.

class RabbitMQConsumer {
  constructor(connection) {
    this.connection = connection;
    this.channel = null;
    this.inFlight = new Set();
  }

  async start(queue, handler) {
    this.channel = await this.connection.createChannel();
    await this.channel.assertQueue(queue);

    this.channel.consume(queue, async (msg) => {
      if (!msg) return;
      this.inFlight.add(msg);
      try {
        await handler(msg);
        this.channel.ack(msg);
      } catch (err) {
        this.channel.nack(msg, false, true);
      } finally {
        this.inFlight.delete(msg);
      }
    });

    console.log(`Consuming from queue: ${queue}`);
  }

  async drain(timeoutMs = 10000) {
    console.log(`Draining ${this.inFlight.size} in-flight messages`);

    this.channel.cancel(this.channel.consumerTag);
    console.log("Consumer cancelled, no new messages");

    const start = Date.now();
    while (this.inFlight.size > 0) {
      if (Date.now() - start > timeoutMs) {
        console.log(`Timeout: ${this.inFlight.size} messages still in-flight`);
        break;
      }
      console.log(`Waiting for ${this.inFlight.size} messages...`);
      await new Promise(r => setTimeout(r, 200));
    }

    await this.channel.close();
    console.log("Channel closed");
  }

  async shutdown() {
    await this.drain();
    await this.connection.close();
    console.log("RabbitMQ connection closed");
  }
}

console.log("RabbitMQ consumer with drain support initialized");

Kafka Consumer Draining

Kafka consumers need to call consumer.disconnect() after stopping and committing offsets.

class KafkaConsumerDrainer {
  constructor(consumer) {
    this.consumer = consumer;
    this.processing = new Map();
  }

  async start(topics, handler) {
    await this.consumer.connect();
    await this.consumer.subscribe({ topics });

    await this.consumer.run({
      eachMessage: async ({ topic, partition, message }) => {
        const key = `${topic}:${partition}:${message.offset}`;
        this.processing.set(key, { topic, partition, offset: message.offset });

        try {
          await handler(message);
          this.processing.delete(key);
        } catch (err) {
          this.processing.delete(key);
          throw err;
        }
      }
    });

    console.log(`Kafka consumer subscribed to: ${topics}`);
  }

  async drain(timeoutMs = 15000) {
    console.log(`Draining ${this.processing.size} in-flight Kafka messages`);

    await this.consumer.stop();
    console.log("Consumer stopped, no new messages");

    const start = Date.now();
    while (this.processing.size > 0) {
      if (Date.now() - start > timeoutMs) {
        console.log(`Timeout: ${this.processing.size} messages still processing`);
        break;
      }
      await new Promise(r => setTimeout(r, 200));
    }

    await this.consumer.disconnect();
    console.log("Kafka consumer disconnected");
  }
}

console.log("Kafka consumer drainer initialized");

Redis Pub/Sub Cleanup

Redis pub/sub connections are long-lived and must be unsubscribed before disconnecting.

const Redis = require("ioredis");

class RedisPubSubManager {
  constructor() {
    this.subscriber = null;
    this.publisher = null;
    this.subscriptions = new Map();
  }

  async connect() {
    this.subscriber = new Redis();
    this.publisher = new Redis();
    console.log("Redis pub/sub connections established");
  }

  async subscribe(channel, handler) {
    await this.subscriber.subscribe(channel);
    this.subscriber.on("message", (ch, message) => {
      if (ch === channel) {
        handler(message);
      }
    });
    this.subscriptions.set(channel, handler);
    console.log(`Subscribed to Redis channel: ${channel}`);
  }

  async drain(timeoutMs = 3000) {
    console.log(`Unsubscribing from ${this.subscriptions.size} channels`);

    const channels = Array.from(this.subscriptions.keys());
    if (channels.length > 0) {
      await this.subscriber.unsubscribe(channels);
      console.log("Unsubscribed from all channels");
    }

    await this.subscriber.quit();
    console.log("Redis subscriber disconnected");
  }

  async shutdown() {
    await this.drain();
    if (this.publisher) {
      await this.publisher.quit();
      console.log("Redis publisher disconnected");
    }
  }
}

const redis = new RedisPubSubManager();
redis.connect();
redis.subscribe("notifications", (msg) => console.log("Received:", msg));
console.log("Redis pub/sub manager ready");

Graceful Consumer with Backpressure

Handle backpressure by pausing consumption when too many messages are in-flight.

class BackpressureConsumer {
  constructor(channel, queue, options = {}) {
    this.channel = channel;
    this.queue = queue;
    this.maxInFlight = options.maxInFlight || 10;
    this.inFlight = 0;
    this.paused = false;
  }

  async start(handler) {
    this.channel.prefetch(this.maxInFlight);
    this.channel.consume(this.queue, async (msg) => {
      this.inFlight++;
      try {
        await handler(msg);
        this.channel.ack(msg);
      } catch (err) {
        this.channel.nack(msg, false, true);
      } finally {
        this.inFlight--;
        if (this.paused && this.inFlight < this.maxInFlight * 0.8) {
          this.channel.resume();
          this.paused = false;
          console.log("Consumer resumed");
        }
      }
    });
  }

  async drain() {
    console.log(`Draining ${this.inFlight} in-flight messages with backpressure`);
    this.channel.cancel(this.channel.consumerTag);
    while (this.inFlight > 0) {
      await new Promise(r => setTimeout(r, 100));
    }
    await this.channel.close();
    console.log("Consumer with backpressure closed");
  }
}

console.log("Backpressure consumer initialized");

Common Mistakes

  1. Closing the connection before the channel -- RabbitMQ requires closing channels before the connection. Closing the connection first leaves channels in an unknown state.

  2. Not acknowledging in-flight messages -- Unacknowledged messages remain in the queue and are redelivered when the consumer reconnects, potentially causing duplicates.

  3. Cancelling consumers without draining -- Calling consumer.cancel() stops message delivery but in-flight messages are still being processed. Drain first, then cancel.

  4. Ignoring Kafka consumer group rebalancing -- During shutdown, a Kafka consumer leaving the group triggers rebalancing. Commit offsets before disconnecting to minimize reprocessing.

  5. Not handling Redis connection errors during shutdown -- Redis connections may already be in an error state during shutdown. Handle errors gracefully and force close if needed.

Practice Questions

  1. What is the correct order for closing RabbitMQ resources? Cancel consumer -> drain in-flight messages -> close channel -> close connection.

  2. Why should you commit Kafka offsets before disconnecting? Committing offsets ensures the next consumer in the group starts from the correct position, minimizing duplicate processing.

  3. How does backpressure work in message queue consumers? Use prefetch count (QoS) to limit how many unacknowledged messages are delivered. Pause consumption when the limit is reached, resume when in-flight count drops.

  4. Challenge: Implement a universal message queue drainer that handles RabbitMQ, Kafka, and Redis with a common interface.

class UniversalQueueDrainer {
  constructor() {
    this.consumers = [];
  }

  addConsumer(name, drainFn) {
    this.consumers.push({ name, drain: drainFn });
  }

  async drainAll(timeoutMs = 15000) {
    console.log(`Draining ${this.consumers.length} queue consumers`);
    const results = await Promise.allSettled(
      this.consumers.map(({ name, drain }) =>
        Promise.race([
          drain(),
          new Promise((_, reject) =>
            setTimeout(() => reject(new Error(`${name} drain timeout`)), timeoutMs)
          )
        ]).then(() => ({ name, status: "ok" }))
      )
    );
    results.forEach(r => {
      if (r.status === "fulfilled") {
        console.log(`${r.value.name}: drained`);
      } else {
        console.error(`${r.reason.message}`);
      }
    });
  }
}

const drainer = new UniversalQueueDrainer();
drainer.addConsumer("rabbitmq", () => Promise.resolve());
drainer.addConsumer("kafka", () => Promise.resolve());
drainer.addConsumer("redis", () => Promise.resolve());
drainer.drainAll();

FAQ

Can I reuse a RabbitMQ channel after closing it?

No. Create a new channel from the connection. Channels are designed for single-use in most connection management patterns.

What happens to unacknowledged messages when the consumer disconnects?

RabbitMQ requeues unacknowledged messages for delivery to another consumer. Kafka rebalances and the new consumer starts from the last committed offset.

How long should the queue drain timeout be?

10-15 seconds for most use cases. Message processing should be fast. If processing takes longer, use a separate queue for long-running tasks.

Should I close the publisher before or after the consumer?

Close the publisher first to stop sending new messages, then close the consumer. This prevents the publisher from sending messages that no consumer will process.

How do I handle message queues in serverless environments?

Serverless functions have limited shutdown control. Set message visibility timeouts appropriately and let the queue service handle redelivery.

Mini Project

Build a message queue manager that handles RabbitMQ consumers, Kafka consumers, and Redis pub/sub with a unified shutdown interface, in-flight tracking, and timeout protection.

class QueueShutdownManager {
  constructor() {
    this.workers = [];
  }

  addWorker(name, worker) {
    this.workers.push({ name, worker });
  }

  async shutdownAll() {
    for (const { name, worker } of this.workers) {
      console.log(`Shutting down ${name}...`);
      try {
        await worker.drain();
        console.log(`${name} shutdown complete`);
      } catch (err) {
        console.error(`${name} shutdown error:`, err.message);
      }
    }
    console.log("All queue workers shut down");
  }
}

const manager = new QueueShutdownManager();
manager.addWorker("file-processor", {
  drain: async () => { console.log("File processor drained"); }
});
manager.addWorker("email-sender", {
  drain: async () => { console.log("Email sender drained"); }
});
manager.shutdownAll();
// Shutting down file-processor...
// File processor drained
// Shutting down email-sender...
// Email sender drained
// All queue workers shut down

What's Next

Now that you understand closing message queues, learn about health check management during shutdown. Then explore Kubernetes pod termination lifecycle.

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro