Skip to content

Idempotent consumers, without the ceremony

What actually happens when the same message arrives twice, and the smallest set of changes that makes a consumer safe to retry.

, 9 min read

Every message broker I have used promises at-least-once delivery, and every one of them means it. Sooner or later the same message arrives twice: a consumer crashes after doing the work but before acknowledging, a rebalance hands a partition to someone else mid-batch, or a producer retries a send that had actually succeeded.

The usual advice is "make your consumers idempotent", which is correct and not very helpful. This note is the smallest set of changes that got me there, without a framework or a distributed lock.

What actually goes wrong

Take a consumer that reserves stock for an order. Processing the same OrderPlaced event twice reserves the stock twice. Nothing errors; the numbers are just quietly wrong until someone counts the shelf.

  • The consumer commits the database change, then dies before acking.
  • The broker redelivers after the visibility timeout.
  • The second delivery runs the same business logic against new state.

The smallest fix: record what you have processed

Give every message a stable ID at the producer, and record that ID in the same transaction as the side effect. If the insert conflicts, the work has already been done and the message can be acknowledged and dropped.

ReserveStockConsumer.java
@Transactional
public void handle(OrderPlaced event) {
    var messageId = event.messageId();

    // Same transaction as the side effect: both commit or neither does.
    int inserted = processedMessages.insertIfAbsent(messageId);
    if (inserted == 0) {
        return; // already processed — safe to ack
    }

    inventory.reserve(event.sku(), event.quantity());
}

The table behind it is deliberately boring: a primary key on the message ID and a timestamp so old rows can be pruned.

sql
CREATE TABLE processed_messages (
    message_id   UUID PRIMARY KEY,
    processed_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

Choosing where the guarantee lives

There are three reasonable places to put the check. I compared them on the things that actually hurt in practice:

Where to enforce idempotency
ApproachCostFails when
Processed-messages tableOne extra insert per messageSide effect is outside the database
Natural idempotency (upserts)Free, if the domain allows itThe operation is not naturally repeatable
Broker-level exactly-onceThroughput and operational complexityAnything leaves the broker’s transaction

What I would do again

Start with natural idempotency wherever the domain allows it, and reach for the processed-messages table for everything else. Exactly-once settings are worth understanding, but they solve a narrower problem than their name suggests.

Filed under

  • Messaging
  • Kafka
  • PostgreSQL

Updated

Related

  1. System design, , 11 min read

    RabbitMQ vs Kafka

    Not a benchmark. A comparison of the questions each one makes you answer: about ordering, replay, consumers and who owns the offset.

    • Messaging
    • Kafka
    • RabbitMQ
  2. Journal, , 8 min read

    Rebuilding the billing flow as events

    Why the second version of an event-driven design is usually the simpler one, and the retry question that decided the whole shape.

    • Building
    • Kafka
  3. Article, , 6 min read

    Indexes I added, then removed

    Three index changes that looked obvious in review and cost more than they saved once real traffic arrived.

    • Databases
    • PostgreSQL

Next

Have a system worth arguing about?

If this note raised a question, or you would have designed it differently, I would like to hear it.

Start a conversation