Blog

Process Once, Receive Many: Building Idempotent Consumers

Because nearly every broker delivers at-least-once, your consumers will see duplicate messages, and idempotency is the discipline that keeps duplicates from corrupting state.

April 3, 20266 min readMuhammad Shehzaib
RELIABILITYMESSAGINGIDEMPOTENCYCONSUMERS

Here is a truth that surprises many developers new to message queues: your consumer will receive the same message more than once. RabbitMQ, Kafka, SQS, and BullMQ all default to at-least-once delivery, which means a message can be delivered twice whenever a network blip, a crash before acknowledgement, or a retry occurs. This is not a bug; it is the only honest guarantee a distributed system can make, because a consumer can always die in the gap between doing its work and confirming it. The defense is idempotency: designing your consumer so that processing the same message twice produces the same result as processing it once. Without it, duplicates mean double charges, duplicate emails, and corrupted counters. With it, duplicates become harmless. This article explains why duplicates are inevitable and gives you concrete, battle-tested patterns for making your consumers safe against them.

Why Duplicates Are Inevitable

Imagine a consumer that processes a message, performs a database write, and then acknowledges it to the broker. Now imagine the process crashes in the instant between the write and the acknowledgement. The broker never received the ack, so by its at-least-once contract it redelivers the message to another consumer, which performs the same write again. There is no way to eliminate this window: whether you ack before or after the work, one ordering risks losing messages and the other risks duplicating them, and since losing messages is usually unacceptable, brokers choose at-least-once and accept duplicates. Network partitions, consumer timeouts, and rebalancing in Kafka all create the same effect. Exactly-once delivery across external side effects is essentially impossible, so the industry consensus is at-least-once delivery plus idempotent processing. Once you accept that duplicates will happen, you stop trying to prevent them and start designing to absorb them.

What Idempotency Really Means

An operation is idempotent if applying it multiple times has the same effect as applying it once. Setting a user's status to active is naturally idempotent: doing it twice leaves the same state. Incrementing a balance by ten is not idempotent: doing it twice adds twenty. The goal is to make your message handlers behave like the first kind regardless of how the underlying operation is shaped. Sometimes you achieve this by restructuring the operation itself into an absolute set rather than a relative change. Other times you cannot change the operation, like sending an email, and must instead track which messages you have already processed and skip the repeats. Idempotency is not a single technique but a property you engineer toward, choosing per operation between making the work naturally repeatable or adding a deduplication layer that remembers what was already done.

The Dedup Key Pattern

The most general technique is deduplication using a unique identifier on every message. The producer assigns each message a stable id, often a UUID or a business key like an order id, and the consumer records the ids it has successfully processed. Before doing the work, the consumer checks whether it has already seen that id; if so, it skips the work and simply acknowledges. The critical detail is that recording the id and performing the side effect must be atomic, or you reopen the very crash window you are trying to close. The cleanest way to get that atomicity is to write the processed id inside the same database transaction as your business change, so either both commit or neither does. The id must be assigned by the producer and remain stable across redeliveries; generating it in the consumer defeats the purpose entirely.

Database Constraints As Guards

Your database is often the simplest place to enforce idempotency. A unique constraint turns duplicate processing into a harmless, catchable error. If each message creates a row keyed by the message id or a natural business key, inserting the same message twice violates the constraint, and your consumer catches that specific error and treats it as a successful no-op. This pushes the deduplication into the storage layer where atomicity is guaranteed by the transaction, eliminating the race conditions that plague application-level checks. Upserts achieve something similar: an insert-or-update on a unique key naturally collapses repeated messages into one final state. The beauty of this approach is that it needs no separate dedup table or external cache; the same constraint that protects your data integrity also protects you from duplicates. When your processing fundamentally writes rows, lean on unique constraints first.

Idempotency In Mongo And Redis

In a MERN stack you can apply these ideas with the tools you already have. In MongoDB, an update with upsert true on a unique business key makes a write idempotent, and a unique index on a message id field rejects duplicate inserts the same way a relational constraint would. For lightweight dedup you can store processed message ids in Redis with the SETNX command, which sets a key only if it does not already exist and returns whether it succeeded; if the key was already present, you know the message is a duplicate and skip it. Give those Redis keys a sensible expiry so the dedup store does not grow without bound, sizing the window longer than your maximum redelivery delay. Be careful, though: a Redis-only check followed by a separate database write is not atomic, so for strong guarantees enforce uniqueness in the database itself.

Atomicity And The Outbox

The hardest idempotency problems arise when one message triggers both a database change and a new message to another service. If you write to the database and then publish, a crash in between loses the publish; if you publish first, a crash loses the database change. The transactional outbox pattern solves this. Instead of publishing directly, your consumer writes the outgoing message into an outbox table inside the same transaction as its business change, so both commit atomically. A separate relay process then reads the outbox and publishes to the broker, marking rows as sent. Because the relay itself can crash and republish, downstream consumers must still be idempotent, but you have eliminated the dangerous gap between updating state and emitting events. This pattern, sometimes paired with change data capture reading the database log, is the standard way to get reliable messaging that stays consistent with your data.

Ordering And Concurrency Hazards

Idempotency interacts with ordering and parallelism in ways that bite the unwary. If two duplicate messages arrive nearly simultaneously and two workers process them in parallel, a naive check-then-act sequence has a race: both check, both see nothing processed, both proceed. This is why atomic operations matter so much; a unique constraint or a single atomic upsert closes the gap that a separate read and write leaves open. Ordering is a second hazard. When you scale to competing consumers, messages may be processed out of order, so an idempotent handler must also tolerate seeing a newer state update before an older one. Including a version number or timestamp in the message and applying updates only when they are newer than the stored version, a conditional write, protects against stale overwrites. Designing for both duplicates and reordering at once is what makes a consumer truly robust under real concurrency.

A Practical Consumer Checklist

Bring it together with a repeatable recipe. First, ensure every message carries a stable, producer-assigned unique id that survives redelivery. Second, decide per operation whether you can make the work naturally idempotent, like an absolute set or an upsert, or whether you need explicit deduplication. Third, make the dedup record and the business side effect atomic, ideally in one database transaction, so a crash cannot leave them inconsistent. Fourth, prefer database unique constraints over application-level checks to avoid concurrency races. Fifth, include a version or timestamp so out-of-order deliveries do not overwrite newer data. Sixth, acknowledge to the broker only after the work and its dedup record have durably committed. Finally, test the duplicate path explicitly by replaying the same message and asserting the state is unchanged. Follow this and at-least-once delivery stops being a threat and becomes a property you have safely engineered around.