System Design Masterclass · Module 16 / Week 3

Idempotency & the Exactly-Once Illusion

Modules 12 and 15 proved duplicates are inevitable — retries and at-least-once delivery guarantee them. This module makes duplicates harmless: idempotency keys, storage design, natural idempotency, and the fencing that makes redrive and retry safe by construction.

~2.5h study~1.5h exercise1 interactive simFast track ~70 min

Fast track — dedupe tables in prod?

Compresses to ~70 min. Motivation collapses; key design, the concurrent-duplicate storage subtleties, and natural idempotency stay mandatory.

  1. §01 sim once · 5 min
  2. §02 key design, in full · 15 min · MANDATORY
  3. §03 storage design, in full · 20 min · MANDATORY
  4. §04 natural idempotency, in full · 15 min · MANDATORY
  5. Exercise step 1 · 15 min
STEP 01

The double-charge, live

SKIM
Fast-track takeawayA timed-out purchase call retried without a key = two charges (M12's ambiguous timeout). The same request with an idempotency key = second attempt returns the recorded first result. That's the entire pattern; §02–§03 are the details that make it correct under concurrency.
Purchase, timeout, retry

Request log

Charges table

The first attempt succeeds server-side but the response is lost (timeout). The client — correctly, per M12 — retries.
STEP 02

Key design

MANDATORY
STEP 03

Storage design — where the bugs live

MANDATORY

Fast trackFull read. Every subtlety here is a production bug you'll otherwise meet: the check-then-act race, the in-progress state, the crash-between, the TTL horizon.

STEP 04

Natural idempotency: design it away

MANDATORY

The best dedupe table is the one you don't need. Operations idempotent by construction:

Staff expectationThe review reflex: for every write endpoint and every consumer, ask "what happens if this executes twice, concurrently, ten minutes apart?" and require the answer to be "nothing" by one of: natural idempotency (preferred), same-transaction dedupe, or a documented accepted risk. Making that question a PR-template checkbox does more for your estate than any single implementation.
STEP 05

Code: the same-transaction pattern

SKIM
Fast-track takeawayOne snippet: atomic key reservation via ON CONFLICT, three-state handling, business write in the same transaction, stored response replay. It's ~30 lines and it's the whole pattern — compare against your services' interceptor.
@Transactional
public PurchaseResult purchase(String key, String userId, PurchaseReq req) {
  int reserved = jdbc.update("""
      INSERT INTO idem (key, user_id, endpoint, payload_hash, state)
      VALUES (?, ?, 'purchase', ?, 'IN_PROGRESS')
      ON CONFLICT (key, user_id, endpoint) DO NOTHING""",
      key, userId, hash(req));

  if (reserved == 0) {                              // duplicate — we lost the race or arrived later
    var rec = idemRepo.find(key, userId, "purchase");
    if (!rec.payloadHash().equals(hash(req)))
      throw new ConflictException("same key, different payload");   // client bug — surface it
    return switch (rec.state()) {
      case COMPLETED   -> rec.storedResult();          // replay the first answer, verbatim
      case FAILED      -> rec.storedResult();          // deterministic failures replay too
      case IN_PROGRESS -> throw new RetryLaterException();  // 202 + Retry-After; never re-execute
    };
  }

  PurchaseResult result = doPurchase(userId, req, key); // key passed to PSP downstream too
  idemRepo.complete(key, userId, "purchase", result);   // SAME transaction as doPurchase's writes:
  return result;                                        // commit is atomic — no gap to crash into
}
STEP 06

Exercise

MANDATORY

Fast trackStep 1 (~30 min): racing your own implementation with concurrent duplicates is the test that matters.

1
Build and race it. Implement §05 against Postgres. Attack it: 50 concurrent identical requests (same key) — assert exactly one charge row; then the SELECT-then-INSERT version — count the duplicates it lets through. Then kill the JVM mid-IN_PROGRESS and verify the lease-expiry recovery path.
2
Close the M15 loop. Take Week-3 Exercise 15.1's duplicate-producing consumer and make it harmless two ways: (a) dedupe table on messageId in the same transaction as the entitlement write; (b) redesign the event as state-carrying + version-guarded upsert with no dedupe table. Argue in two sentences which you'd ship.
3
Paper. Inventory your platform's top 10 write operations and consumers: classify each as naturally idempotent / keyed / unprotected. The unprotected ones ranked by blast radius = a prioritized reliability backlog you can hand to teams tomorrow.

Self-check

Why must the dedupe check be atomic with reservation, not read-then-write?
Duplicates arrive concurrently by nature (retry + original racing, redelivery + redrive): both read "absent", both proceed. Only an atomic claim — unique-constraint INSERT, SET NX — collapses the race to one winner; the loser learns the truth from the conflict.
Why replay the stored FAILURE for a duplicate instead of retrying the operation?
If the failure was deterministic (card declined, invalid content), re-executing re-runs a side-effecting attempt the business already answered — possibly re-pinging the PSP, re-alerting fraud, or now-succeeding against changed state the client isn't expecting. The key's contract is "one intent, one outcome"; a new attempt deserves a new key, which is precisely how the client signals "the user tried again".
Your dedupe lives in Redis with TTL 1h; DLQ redrive happens after 3 days. What breaks?
Every redriven message is past the dedupe horizon — all reprocess as fresh, duplicating whatever they did the first time. Retention must exceed the longest redelivery path (DLQ age limit, Kafka retention). Fix: dedupe TTL ≥ max redrive age, or naturally idempotent consumers so the horizon doesn't matter — the stronger fix.
Rank for a "grant entitlement" consumer: dedupe table vs version-guarded upsert. Which and why?
Version-guarded upsert on (userId, contentId): the DB constraint is the mechanism — no extra table, no TTL horizon, no crash-between gap, and it also absorbs reorder. Dedupe tables are for operations without a natural unique target (a PSP charge). Rule of thumb: reach for the dedupe table only after failing to find the natural key.