Skip to main content
BlogBackend

The Idempotent Consumer Pattern for Message Queues

Your broker delivers at-least-once, so duplicates are guaranteed. Here is how a processed-messages table turns that into exactly-once effects.

Matthews Wong

Software Engineer

July 20269 min read
The Idempotent Consumer Pattern for Message Queues

Photo by Enoch Leung from Canada via Wikimedia Commons (CC BY-SA 2.0)

Key Takeaway

Message brokers guarantee at-least-once delivery, so the same message will eventually arrive twice. The idempotent consumer pattern fixes this: record each processed message ID in a database table with a unique constraint, and apply that insert plus the side effect inside one transaction. Duplicates hit the constraint, roll back, and change nothing.

Every message queue I have run in production, from RabbitMQ to Kafka to SQS, makes the same promise: at-least-once delivery. Not exactly-once. That word matters. It means the broker will redeliver a message if it is not sure you finished processing it, and it is frequently not sure. A consumer crashes after doing the work but before acknowledging. A network blip drops the ack. A visibility timeout expires while you were still busy. In every case the broker does the safe thing and hands you the message again.

So duplicates are not an edge case you might hit. They are a certainty you must design for. If your consumer charges a card, sends an email, or decrements inventory, processing the same message twice is a real bug with real money attached. The idempotent consumer pattern is the standard, boring, correct answer, and it lives almost entirely in your database.

Why exactly-once delivery is a myth

Exactly-once delivery across a network is impossible to guarantee, because the acknowledgement itself can be lost. If the consumer acks and the ack vanishes, the broker must choose: redeliver and risk a duplicate, or drop and risk losing the message forever. At-least-once picks the first, which is the only safe choice. What vendors market as exactly-once is really at-least-once delivery plus idempotent processing on the consumer side. Kafka's exactly-once semantics work inside the Kafka boundary; the moment you write to Postgres or call Stripe, you are back to needing your own idempotency.

Idempotent means running the operation N times leaves the system in the same state as running it once. That is the property you are engineering for, not zero duplicates. You cannot stop duplicates from arriving; you can make them harmless.

The processed-messages table

The whole pattern rests on one table. Each row is the fingerprint of a message you have already handled. The message ID column carries a unique constraint, which is the load-bearing element of the entire design. When a duplicate arrives, the insert violates that constraint and fails loudly, and that failure is exactly the signal you want.

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

-- The composite key lets different consumers each
-- process the same message once, independently.

Two design notes. First, key on the business message ID, not the broker offset or the delivery tag. Offsets reset, tags are per-connection; a stable producer-assigned ID survives redelivery and rebalancing. Second, include the consumer name in the key when multiple distinct consumers subscribe to the same topic, so each gets its own once-only guarantee.

One transaction is the whole trick

The subtle mistake is to check the table, then process, then insert. That leaves a gap: two duplicate deliveries can both read empty, both process, both insert. The correct shape wraps the dedup insert and the business side effect in a single database transaction. If the insert succeeds, do the work and commit. If the insert hits the unique constraint, the message is a duplicate, so roll back and acknowledge it without doing anything. The atomicity is what makes it safe under concurrency.

// NestJS consumer, TypeORM data source
async function handle(msg: IncomingMessage): Promise<void> {
  await dataSource.transaction(async (tx) => {
    try {
      await tx.insert(ProcessedMessage, {
        messageId: msg.id,
        consumer: 'billing-service',
      });
    } catch (err) {
      if (isUniqueViolation(err)) {
        // Already processed. Roll back, ack, move on.
        return;
      }
      throw err; // real error -> nack -> redelivery
    }

    // Same transaction as the dedup row:
    await tx.update(Order, msg.orderId, { status: 'PAID' });
  });
}

// Postgres unique-violation SQLSTATE
function isUniqueViolation(e: unknown): boolean {
  return (e as { code?: string })?.code === '23505';
}

This only holds when the side effect and the dedup row share one transactional store. If your side effect is an external API call, the database transaction cannot roll it back. That is a different problem, covered below.

When the side effect leaves your database

A local row update rolls back cleanly. Charging a card through Stripe does not; once that HTTP call succeeds, the money moved, and no transaction rollback un-charges it. Here you push idempotency out to the third party. Most serious payment and messaging APIs accept an idempotency key, and the IETF has an active Standards Track draft for an Idempotency-Key HTTP header, revision 07 as of late 2025. Send a stable key derived from the message ID; the provider deduplicates on their side and returns the original result on retry.

  • Derive the idempotency key deterministically from the message ID, so a redelivery produces the exact same key.
  • Persist the external call's result in your transaction before committing, so you never repeat the call on the next redelivery.
  • Order operations so the external effect is the last thing before commit, shrinking the window where a crash leaves work half-done.

Cleanup, growth, and the details that bite

The processed-messages table grows forever if you let it. You do not need rows older than your broker's maximum redelivery window, which is usually hours, not months. Prune on a schedule and the table stays small and its unique index stays fast.

-- Nightly cron; keep a generous safety margin over
-- the broker's redelivery / retention window.
DELETE FROM processed_messages
WHERE processed_at < now() - INTERVAL '7 days';
DecisionWrong wayRight way
Dedup keyBroker offset or delivery tagProducer-assigned business message ID
Check vs insertSELECT, then process, then INSERTINSERT first, let the unique constraint decide
Transaction scopeDedup row and side effect in separate commitsBoth in one atomic transaction
External callAssume the DB transaction protects itPass an idempotency key to the provider
Table growthKeep every row foreverPrune past the redelivery window

Most naturally idempotent operations need no table at all. Setting status to PAID or upserting a record by key is already safe to repeat. Reach for the processed-messages table when the operation accumulates, like incrementing a balance or appending a ledger entry.

Put together, the pattern is unglamorous and that is the point. Assume at-least-once delivery, key on a stable message ID, insert into a table with a unique constraint inside the same transaction as your side effect, push idempotency to any external system, and prune old rows. That is how you build exactly-once effects on a broker that only promises at-least-once, and it is the same shape whether you run Kafka, RabbitMQ, or SQS.

FAQ

Frequently Asked Questions

Short answers to what readers ask most about this topic.

What is the idempotent consumer pattern?

It is a way to make a message-queue consumer safe against duplicate deliveries. You record the ID of every message you process in a database table with a unique constraint, and you do that insert in the same transaction as the actual work. If the same message arrives again, the insert fails on the unique constraint, so you skip the work and acknowledge the duplicate harmlessly.

Why can't message queues just deliver exactly-once?

Because the acknowledgement can be lost in transit. If a consumer finishes work and sends an ack that never arrives, the broker cannot tell the difference between a slow consumer and a dead one. To avoid losing messages it redelivers, which is at-least-once delivery. True exactly-once across a network is provably impossible, so consumers must handle duplicates themselves.

Should I key the dedup table on the broker offset or the message ID?

Use a stable, producer-assigned business message ID, not the broker offset or delivery tag. Offsets can reset and delivery tags are scoped to a single connection, so neither survives redelivery or consumer rebalancing reliably. A message ID that the producer sets stays constant across every redelivery, which is exactly what deduplication needs.

How do I stay idempotent when the side effect is an external API call?

A database transaction cannot roll back an HTTP call that already succeeded, so you push idempotency to the provider. Send an idempotency key derived deterministically from the message ID; most payment and messaging APIs deduplicate on that key and return the original result on retry. The IETF also has an active Standards Track draft for an Idempotency-Key HTTP header.

Won't the processed-messages table grow forever?

It will if you never clean it. You only need rows that are newer than your broker's maximum redelivery or retention window, which is typically hours. Run a scheduled delete that removes rows older than a safe margin, and the table and its unique index stay small and fast.