The Idempotent Consumer Pattern

BackendArchitectureMessaging
Share on LinkedIn

Your payment consumer processes a PaymentCompleted event, credits the user's account, and crashes before committing the Kafka offset. The message redelivers. Without idempotency, the user gets double credit. Every message broker — Kafka, SQS, RabbitMQ, Pub/Sub — delivers duplicates under failure scenarios. "At-least-once" is a feature, not a bug. The idempotent consumer pattern makes duplicates harmless.

The failure scenario

1. Consumer receives msg-123 (pay $50 to account A)
2. UPDATE accounts SET balance = balance + 50 WHERE id = A  ✓
3. Consumer crashes before offset commit
4. Consumer restarts, receives msg-123 again
5. Without idempotency: balance += 50 again  ✗

Deduplication table pattern

CREATE TABLE processed_messages (
    message_id   VARCHAR(255) PRIMARY KEY,
    processed_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
async function handlePaymentEvent(event: PaymentEvent, messageId: string): Promise<void> {
  await db.transaction(async (tx) => {
    const inserted = await tx
      .insert(processedMessages)
      .values({ messageId })
      .onConflictDoNothing()
      .returning();

    if (inserted.length === 0) {
      // Already processed — ack and return
      return;
    }

    await tx
      .update(accounts)
      .set({ balance: sql`balance + ${event.amount}` })
      .where(eq(accounts.id, event.accountId));
  });
}

The unique constraint on message_id makes the insert-and-check atomic within the transaction.

Natural key idempotency

When the business event has a natural idempotency key, skip the dedup table:

async function handleOrderCreated(order: OrderEvent): Promise<void> {
  await db
    .insert(orders)
    .values({
      id: order.orderId,        // natural key from upstream
      customerId: order.customerId,
      total: order.total,
    })
    .onConflictDoNothing();
}

Inserting the same order twice is a no-op. This works when the side effect is "create record with fixed ID."

Idempotency key from producer

Producers should attach stable IDs:

{
  "eventId": "evt_8f3a2b1c",
  "type": "payment.completed",
  "idempotencyKey": "pay_intent_pi_abc123",
  "payload": { "amount": 5000, "accountId": "acc_xyz" }
}

Use idempotencyKey (business-level) over broker messageId when the same business event can be republished with a new message envelope.

Consumer design rules

Rule Why
Process + dedup in one transaction Avoid window between process and record
Make side effects upserts Natural defense against duplicates
Ack after successful commit At-least-once means ack-on-success
Log skipped duplicates at INFO Distinguish from errors in monitoring
TTL old dedup keys Table grows forever otherwise
-- Partition or purge keys older than 30 days
DELETE FROM processed_messages WHERE processed_at < now() - interval '30 days';

Retention must exceed your broker's max redelivery window.

Kafka-specific notes

Enable enable.idempotence=true on producers to prevent duplicate writes from producer retries — this is broker-level dedup, not consumer-level. Consumers still need application idempotency.

For Kafka Streams, use reduce with commutative operations where possible (counts are dangerous; sets and max are safer).

Testing idempotency

test('duplicate message does not double credit', async () => {
  const event = { orderId: 'ord_1', amount: 100, accountId: 'acc_1' };
  await handlePaymentEvent(event, 'msg-001');
  await handlePaymentEvent(event, 'msg-001'); // duplicate

  const account = await db.select().from(accounts).where(eq(accounts.id, 'acc_1'));
  expect(account.balance).toBe(100); // not 200
});

Every consumer handler should have this test. Pair with outbox pattern for reliable publishing.

Ordering and exactly-once semantics

Idempotency handles duplicates but not ordering. If OrderCreated and OrderCancelled arrive out of order, idempotent processing of each individually still produces wrong final state. Solutions:

async function handleOrderEvent(event: OrderEvent): Promise<void> {
  await db.transaction(async (tx) => {
    const inserted = await tx.insert(processedMessages)
      .values({ messageId: event.eventId })
      .onConflictDoNothing()
      .returning();
    if (inserted.length === 0) return;

    const order = await tx.select().from(orders).where(eq(orders.id, event.orderId));
    if (order.version >= event.version) return; // stale event

    await applyTransition(tx, order, event);
  });
}

Partial failure and the outbox boundary

Idempotent consumers work when processing is a single database transaction. When processing spans systems (DB write + external API call), you need the outbox pattern or saga:

Message → idempotent DB write → outbox event → external call via relay

If the external call fails, the outbox retries without re-processing the idempotent DB write. The dedup table and outbox table serve different purposes — dedup prevents double processing; outbox ensures reliable downstream delivery.

SQS, RabbitMQ, and Pub/Sub variants

SQS: Use FIFO queues with deduplication ID for ordering + dedup. Standard queues require application-level dedup table. Visibility timeout must exceed max processing time — otherwise message reappears while still being processed.

RabbitMQ: Manual ack after successful processing. Prefetch count = 1 for ordering per consumer. Dead-letter exchange for failed messages after max retries.

Google Pub/Sub: Enable message ordering with ordering keys. Ack deadline extension for long-running processing. Dead-letter topic for poison messages.

The dedup table pattern is broker-agnostic — implement it regardless of transport.

Dedup table operations at scale

The processed_messages table grows indefinitely without maintenance:

-- Partition by month for efficient purge
CREATE TABLE processed_messages (
    message_id VARCHAR(255) NOT NULL,
    processed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    PRIMARY KEY (message_id, processed_at)
) PARTITION BY RANGE (processed_at);

-- Drop partitions older than retention window
DROP TABLE processed_messages_2024_01;

Retention must exceed: max broker redelivery window + max consumer downtime + clock skew buffer. Typical: 30 days for Kafka, 14 days for SQS.

Index size on high-throughput topics (millions/day) makes partitioning essential — a single-table DELETE is too slow.

Failure modes

Production checklist

Resources

Frequently asked questions

Why do message brokers deliver duplicates?

Brokers guarantee at-least-once delivery by default — a consumer crashes after processing but before acknowledging, and the message redelivers. Network retries, producer retries, and consumer rebalances all cause duplicates. Your consumer must be idempotent: processing the same message twice produces the same result as once.

How do I implement idempotent message processing?

Store a deduplication key (message ID or business idempotency key) in a database with a unique constraint. Before processing, attempt to insert the key — if it already exists, skip processing and ack the message. Alternatively, use upsert operations where the natural key makes repeats harmless.

Is exactly-once delivery possible?

True exactly-once end-to-end requires transactional outbox, idempotent consumers, and broker transactions — complex and broker-specific. Practical systems achieve effectively-once semantics: at-least-once delivery plus idempotent consumers. Design for duplicates from day one.

Hiring a senior Android / Flutter engineer?

I architect and ship production mobile software — Kotlin, Jetpack Compose, Flutter — for robotics, EV infrastructure, fintech, and real-time systems. Open to remote roles in Europe and the US.

Get in touch →