Handling Message Duplication in RabbitMQ: Implementing an Idempotent Consumer with Node.js

Development tutorial - IT technology blog
Development tutorial - IT technology blog

A Classic Problem: Why Are Customers Charged Twice?

Imagine you’re running a payment system. A customer clicks the confirmation button, but due to network jitter, the Consumer finishes processing but fails to send the ACK back to RabbitMQ before the connection drops. RabbitMQ detects the disconnection and redelivers that message to another Consumer. The result? The customer is charged twice. This isn’t a code bug; it’s a fundamental characteristic of distributed systems.

RabbitMQ commits to “at-least-once delivery” (guaranteed delivery at least once), but it does not guarantee “exactly-once”. Message duplication is inevitable. To solve this, we need to transform the Consumer into an Idempotent Consumer. Simply put: whether it receives a message once or 100 times, the system state remains unchanged.

Environment Setup

To follow along, you’ll need Node.js and Docker to quickly spin up RabbitMQ and Redis. We use Redis as a message_id store because its read/write speeds are extremely fast, making it ideal for checking duplicates within milliseconds.

# Quickly run RabbitMQ and Redis
docker run -d --name rabbitmq -p 5672:5672 -p 15672:15672 rabbitmq:3-management
docker run -d --name redis -p 6379:6379 redis:alpine

Initialize the project and install the necessary libraries:

mkdir rabbitmq-idempotency && cd rabbitmq-idempotency
npm init -y
npm install amqplib ioredis uuid

The Strategy: Check First, Process Later

The workflow is straightforward: every outgoing message must include a unique messageId. When the Consumer receives it, it checks Redis. If the ID already exists, skip it immediately. If not, proceed with processing and store that ID in Redis.

1. Producer: Sending Messages with Identifiers

Never send a “naked” payload. Wrap your data in an object containing metadata for better management.

const amqp = require('amqplib');
const { v4: uuidv4 } = require('uuid');

async function sendOrder() {
    const conn = await amqp.connect('amqp://localhost');
    const channel = await conn.createChannel();
    const queue = 'order_queue';

    const message = {
        id: uuidv4(), // Unique identifier for each transaction
        data: { orderId: 'ORD-999', amount: 500000 }
    };

    await channel.assertQueue(queue, { durable: true });
    channel.sendToQueue(queue, Buffer.from(JSON.stringify(message)), {
        persistent: true
    });

    console.log(`[x] Sent order: ${message.id}`);
    setTimeout(() => conn.close(), 500);
}

sendOrder();

2. Consumer: Anti-duplication Mechanism with SETNX

Here, we use the Redis SETNX (Set if Not Exists) command. This is an atomic operation. It ensures that even if 10 instances receive the same message simultaneously, only one instance will gain the right to process it.

const amqp = require('amqplib');
const Redis = require('ioredis');
const redis = new Redis();

async function consume() {
    const conn = await amqp.connect('amqp://localhost');
    const channel = await conn.createChannel();
    const queue = 'order_queue';

    await channel.assertQueue(queue, { durable: true });
    channel.prefetch(1);

    channel.consume(queue, async (msg) => {
        if (!msg) return;
        
        const { id, data } = JSON.parse(msg.content.toString());
        
        // Try writing to Redis with a 24-hour TTL to avoid memory bloat
        const isNew = await redis.set(`msg:${id}`, 'processing', 'NX', 'EX', 86400);

        if (isNew) {
            try {
                console.log(`[v] Processing message: ${id}`);
                await processOrder(data); // Simulate business logic
                channel.ack(msg);
            } catch (err) {
                console.error("Processing error:", err);
                await redis.del(`msg:${id}`); // Delete key to allow retry
                channel.nack(msg, false, true);
            }
        } else {
            console.warn(`[!] Duplicate detected: ${id}. Skipping...`);
            channel.ack(msg); // Still need to ACK to remove from queue
        }
    });
}

async function processOrder(data) {
    return new Promise(res => setTimeout(res, 1000));
}

consume();

In practice, I once operated a system processing over 1 million messages per day. The key lesson learned is that TTL (Time To Live) is crucial. Without a TTL, Redis will bloat tremendously after a few months, wasting resources and slowing down lookup speeds.

Monitoring and Metrics

Don’t just write the code and forget about it. You need to track these metrics:

  • Redis Hit/Miss Rate: If the duplication rate suddenly spikes (e.g., > 5%), there might be a serious network issue.
  • Consumer Lag: Monitor this on the RabbitMQ UI. If Unacknowledged Messages rise, it means your processing logic is slower than the incoming message rate.
  • Dead Letter Queue (DLQ): Always maintain a “graveyard” for messages that fail too many times. Don’t let a faulty message clog the entire processing pipeline.

Building distributed systems means learning to embrace uncertainty. Instead of trying to prevent duplication absolutely, design your Consumer to be smart enough to recognize and reject what it has already processed.

Share: