Skip to content
aipatterns.dev

Design patternData Patterns

Idempotent Consumer

Record the ids of processed messages and skip ids already seen, so a redelivered webhook or queue event processes once no matter how many times it arrives.

Complexity
Low
Seen in the wild
High
Also known as
Deduplication, Message Dedup

Intent

Treat every incoming message as a possible repeat. Before processing, check a store of already-processed message ids; process only unseen messages, and record the id in the same transaction as the work. A message delivered five times then produces exactly one effect, because only the first delivery finds an unseen id.

Forces

  • At-least-once delivery vs once-only effects: queues, relays and webhooks guarantee at-least-once at best; effects like charges and inserts happen in a world that counts.
  • Check vs process: the dedup check and the business work must commit together, or a crash between them turns the id record into a lie - either a lost message or a repeated one.
  • Retention vs safety: keeping every id forever is perfect and unbounded; keeping a window risks reprocessing an ancient redelivery.
  • Sender cooperation vs receiver discipline: an idempotency key from the sender is ideal, but the receiver must assume keys can repeat or be absent and dedupe on its own ids.

Therefore

Give every message an id the sender keeps stable across retries; before processing, atomically claim the id - insert it into a processed-ids store keyed uniquely - and process only if the claim is the first.

The claim, not the check-then-act, is the pattern: two concurrent deliveries race to insert the same id, the unique key lets exactly one win, and the loser skips. Process the message inside the claim's transaction so a crash either leaves both the work and the id uncommitted (redelivery retries) or both committed (redelivery skips).

Structure

Diagram source (Mermaid)
flowchart TD
  A[Message arrives] --> B{Claim the message id}
  B -- claimed --> C[Process the work in the claim's transaction]
  C --> D[Commit work + id]
  B -- already claimed --> E[Skip: acknowledge as duplicate]

Participants

  • Message (Event): payload plus a stable id - the provider's event id or a sender-generated idempotency key that survives retries.
  • Processed-ids store (Processed): rows keyed uniquely by message id, written in the same transaction as the work. Holds ids for a retention window.
  • Consumer (WebhookReceiver): claims, processes, commits - or recognizes the duplicate and acknowledges without work.
  • Handler (grantCredit): the business effect. Runs zero times or once per message id.

Use it when

  • Delivery is at-least-once by contract: webhook providers redeliver on missing 2xx, queues redeliver on unacknowledged messages, and the Transactional Outbox relay publishes again after a crash.
  • The effect counts: charges, credits, inserts, sends. Read-only handlers need none of this.
  • Retries exist anywhere in the path - the sender retries on timeout, or a Saga re-runs a step after compensation (Retry).
  • You receive webhooks from a provider that documents redelivery (Stripe, GitHub) and you must never double-apply an event.

Do not use it when

  • The handler is read-only or naturally idempotent - setting a field to a fixed value, deleting by id. Re-delivery changes nothing; the store is ceremony.
  • The transport contractually delivers exactly once and you believe it for your risk tolerance - rare, expensive, and usually configured per-flow.
  • The same message legitimately arrives under different ids for different business events. Dedupe on the business operation, not the transport id - or you will drop real work.
  • You control both ends and the sender deduplicates already. One honest dedup layer per path is enough; two is two things to debug.

Application grill

Inspect first: the ingestion points (webhooks, queue consumers) and whether redelivery is already a fact (provider retries, at-least-once queues); an existing store of processed ids (outbox table, dedupe table, Redis keys); whether handlers are safe to run twice today (blind inserts versus upserts).

Missing pieces: a durable record of seen ids. The default is a dedupe table with a unique constraint in the existing database, checked in the same transaction as the work. Redis with a TTL suits high volume where a duplicate after expiry is harmless. Whatever the store, the handler must still tolerate a second run (upsert, not insert).

Ask before you build:

  • Scale: how many messages per second arrive, and how long can a duplicate arrive late (the retention window for seen ids)?
  • Performance: can the dedupe check share the business transaction (one round trip), or does it add one?
  • Reliability: is the id recorded only after the work commits, so a crash mid-handler redelivers safely?
  • Budget: who prunes the dedupe store, and what does its growth cost?
  • Implementation: which id is the dedupe key (provider event id versus entity plus operation), and is there a test that replays a redelivery?

Implementation steps

  1. Choose the id: the provider's event id when it exists; otherwise require an idempotency key from the sender, or derive a stable id from the operation (order id + operation name).
  2. Make the store of processed ids part of the processing transaction: insert the id with a unique constraint and do the work, then commit. Never check-then-insert in separate transactions.
  3. On a unique-violation, treat the message as a duplicate: acknowledge it and move on. Log at info level - duplicates are the contract working, not errors.
  4. Set a retention window longer than the sender's maximum redelivery horizon, and say it in days, not vibes. Purge expired ids on a schedule; a dedup store that grows forever is its own incident.
  5. Handle the concurrent case explicitly: a unique constraint settles the race; an upsert-and-report-rows-inserted achieves the same without catching exceptions.
  6. Keep processing inside the claim even when the handler calls other services - if the remote call fails after the claim commits, redelivery is skipped, so the handler itself must be retryable or the claim must roll back on failure.
  7. Alert on duplicate rates per sender: a sudden rise means their retry policy changed or your acknowledgments stopped arriving.

Example

A credit webhook that providers redeliver on any non-2xx. The receiver claims the event id in the same transaction as the credit; the first delivery grants and records, a redelivery is acknowledged as a duplicate, and a crash mid-handler rolls the claim back so the redelivery succeeds.

type Event = { id: string; account: string; cents: number };

// The database: the claim and the credit commit together or not at all.
class Store {
  private claimed = new Set<string>();
  private balances = new Map<string, number>();

  begin() {}
  commit() {}
  rollback() {} // a real store reverts uncommitted writes here

  claim(id: string): boolean {
    if (this.claimed.has(id)) return false; // unique key says: duplicate
    this.claimed.add(id);
    return true;
  }
  grantCredit(account: string, cents: number): void {
    this.balances.set(account, (this.balances.get(account) ?? 0) + cents);
  }
  balance(account: string): number {
    return this.balances.get(account) ?? 0;
  }
}

class WebhookReceiver {
  constructor(private readonly store: Store) {}

  /** Returns "processed" or "duplicate"; providers only need a 2xx. */
  handle(event: Event): string {
    this.store.begin();
    try {
      if (!this.store.claim(event.id)) {
        this.store.commit();
        return `duplicate: ${event.id} skipped`;
      }
      this.store.grantCredit(event.account, event.cents);
      this.store.commit();
      return `processed: ${event.id} (+${event.cents}c)`;
    } catch (e) {
      this.store.rollback(); // claim rolls back too: redelivery will process
      throw e;
    }
  }
}

const store = new Store();
const receiver = new WebhookReceiver(store);
const event: Event = { id: "evt_001", account: "acc-9", cents: 500 };

console.log(receiver.handle(event)); // processed: evt_001 (+500c)
console.log(receiver.handle(event)); // duplicate: evt_001 skipped (redelivery)
console.log(receiver.handle(event)); // duplicate: evt_001 skipped
console.log(`balance: ${store.balance("acc-9")}`); // 500 - granted exactly once

The retention window is part of the correctness claim: an id purged before the sender's redelivery horizon expires lets that ancient duplicate through, so the window must outlast the sender's worst retry schedule.

Trade-offs

Gains

  • Effects count deliveries once, no matter how the transport misbehaves.
  • The dedup decision is transactional with the work - no crash window between "remembered" and "done".
  • Duplicate rates per sender become an observable signal about the pipeline.

Costs

  • A processed-ids store to own, size, and purge; its retention window is a correctness parameter.
  • The claim must share the work's transaction, which couples the dedup store to the primary datastore.
  • Real processing must still be retryable: a failure after the claim commits cannot be fixed by redelivery.
  • Ids must actually be stable across retries - sender cooperation or careful derivation required.

Relations

  • Transactional Outbox: the producing half of the same contract - its relay publishes at least once, which is precisely why this pattern exists on the consuming side.
  • Saga: every saga action and compensation re-runs after crashes; idempotency keys are what make re-running them harmless.
  • Retry: retries are the source of duplicates - the sender's retry policy defines the retention window this pattern must outlast.
  • Command: a claimed message is a command executed once; modeling messages as commands with stable ids makes the dedup key obvious.

Agent checklist

  • Every message has a stable id across retries, and the id choice is written down.
  • The claim and the work share one transaction; a unique constraint settles concurrent deliveries.
  • Duplicates are acknowledged, logged at info, and never re-processed.
  • The retention window outlasts every sender's maximum redelivery horizon.
  • The handler is retryable even after a late claim commit.
  • Duplicate rates per sender are monitored.

See also

Original writing and examples. Topic coverage follows Microsoft Azure Architecture Center, which explains this topic with its own illustrations.