concept
Idempotency
Idempotency
A handler is idempotent if processing the same message N times has the same effect as processing it once. In distributed systems where delivery-guarantees|at-least-once delivery is the practical default, idempotency is what turns “we might see this message twice” into “no user-visible bug.”
Exactly-once delivery over a network? No. Exactly-once effect through idempotent processing? Yes.
Why it’s the universal solvent
You can’t escape duplicates over an unreliable network (see two-generals-problem). Retry storms from network blips. Replays from a DLQ. ACK losses. Every message handler will eventually see a duplicate. If your handler:
- Charges a card → idempotency prevents double charge.
- Sends an email → idempotency prevents the 50-emails-per-signup horror story.
- Increments a counter → idempotency prevents inventory drift.
- Writes to a downstream API → idempotency prevents partial-write inconsistency.
Implementation patterns
1. Event-ID dedup table
Every message carries a unique event ID. The handler:
BEGIN
if exists(processed_events, event_id): RETURN
insert into processed_events (event_id, processed_at)
do the work
COMMIT
Cheap, ergonomic, works for most cases. Periodically prune by TTL.
2. Natural-key idempotency
Use a domain identifier that’s stable across retries — order_id, payment_intent_id, request_id provided by the caller. If the operation is “create an order with this id,” a second call either no-ops or returns the same result.
Stripe’s Idempotency-Key header is this pattern at the API level — see the stripe notes.
3. Idempotent state transitions
Design the state machine so a no-op is naturally correct: mark_paid(order_id) is idempotent because re-marking a paid order is a no-op. add_charge(order_id, $10) is not — re-adding charges $20.
4. Transactional outbox
For “do the work + emit a message” semantics, write the message to a local outbox table in the same transaction as the work, then a separate worker forwards outbox rows to the broker with at-least-once + dedup keys. Combined with consumer-side idempotency, gives you the practical “exactly-once effect.”
What’s not idempotency
- Locks — locks prevent concurrent processing, not duplicate processing across time.
- “It hasn’t happened to us yet” — it will. The first retry storm is when teams find out.
- Exactly-once delivery — doesn’t exist; see two-generals-problem.
The check to run
Open your message handlers. For each one ask: what happens if this runs twice with the same input? If the answer is anything other than “same result as once,” it’s not idempotent. Fix it before the next network blip turns it into a P0.
See also
- delivery-guarantees — why idempotency is mandatory, not optional
- two-generals-problem — the result that makes idempotency necessary
- dead-letter-queue — DLQ replay is one of the duplicate sources
- stripe — Idempotency-Key header at the API level