Lesson 10 concluded that the strongest way to avoid a lock is to make repetition harmless. This lesson is how you do that — and it is also the answer to a problem every distributed system has to face: when a client receives a timeout, it cannot know whether the request was processed.

This lesson's lab fires 78,774 concurrent requests carrying the same idempotency key at a charging endpoint. The result: exactly one row in the database, exactly one 201 Created, and the balance debited exactly once. Turn idempotency off and run the same load: 14,236 rows, 1,423,600 debited — an account 423,600 in the red.

ℹ️ The measurement machine
Apple M1 Max, 10 cores, 32 GB RAM, macOS 26.5.2, Docker 29.6.2. Native arm64 containers (Node v22.23.2), PostgreSQL 18.3, three app replicas at 1 CPU each, nginx 1.27-alpine, load generator on 2 CPUs, 64 concurrent connections for 10 seconds.

The database access layer is app/minipg.js — a hand-written wire-protocol client with no dependencies (introduced in Lesson 7). It does not support parameterised queries, so the endpoint sanitises its input by hand; this is stated in the code and is a limitation of the lab, not a practice to copy.

11.1 Why "exactly-once" is an illusion

A client sends a charge request. It waits, and receives a timeout. The question: did the server charge the money or not? The client has no way to know. The following three situations produce exactly the same experience on the client side:

What actually happened What the client sees What happens if the client retries
The request never reached the server Timeout Correct — a retry is needed
The server finished, and the response was lost on the way back Timeout Wrong — charged a second time
The server is still processing, not finished Timeout Wrong — it may be processed twice in parallel

This is the Two Generals problem: no protocol lets two parties reach absolute certainty that a message arrived, when the channel can lose messages. The consequence: exactly-once delivery is impossible.

But there is a combination that achieves what people actually want: at-least-once delivery + idempotent processing. Keep resending until you are sure it arrived (at-least-once), and design things so that receiving it many times has the effect of receiving it once. From the outside the result looks like exactly-once, even though no step in it is exactly-once.

⚠️ Pitfall: believing "exactly-once delivery" marketing
Several message brokers advertise exactly-once. What they actually provide is exactly-once processing within a narrow scope: when both reading the message and writing the result live inside their system, they can make those two things atomic with each other.

That scope does not include side effects reaching the outside world. If your consumer calls a payment API, sends an email, or pushes a notification, no broker can guarantee that happens only once — because that call is itself subject to the Two Generals problem. For those side effects, you still have to make things idempotent yourself.
Three situations, and ONE thing the client sees: a timeout 1 · The request NEVER reached the server C S the server knows nothing · the balance is UNCHANGED retrying is CORRECT 2 · The server FINISHED, and the response was lost on the way back C S ALREADY CHARGED · the balance has changed retrying ⇒ CHARGED A SECOND TIME 3 · The server is STILL processing, not finished C S still running · outcome unknown retrying ⇒ processed twice IN PARALLEL No protocol can distinguish these three situations from the client's side So do not try to. Make retrying HARMLESS instead: at-least-once + idempotent processing. From the outside that combination looks like exactly-once — even though no step in it is exactly-once.
The Two Generals problem says absolute certainty is impossible. Idempotency is how you make that uncertainty stop mattering.

11.2 Idempotency keys and the dedup table

The mechanism is compact: the client generates a unique key for its intent — not for each attempt. The server stores the key → result mapping in a dedup table (dedup = deduplication: a table whose only job is to remember "this key has been processed, and here is the result"). On seeing a key it already has, the server returns the stored result instead of processing again.

Decision Doing it right Doing it wrong, and the consequence
Who generates the key The client, one key per intent The server generating it ⇒ every attempt is a new key ⇒ nothing can be deduplicated. A client generating a fresh key per attempt has the same consequence
What to store The complete result, so it can be returned again Storing only a "processed" flag ⇒ the retry gets an empty 200, and the client loses the transaction ID with no idea what to do next
The key's TTL Longer than the client's maximum retry span Shorter ⇒ the key expires between two retries, and the second is processed as a new intent
Status code on a duplicate The same code and the same body as the first time Returning 409 Conflict ⇒ the client thinks it failed and may retry with a different key — precisely what we set out to avoid
💡 The key is for the INTENT, not for the attempt
This is the most easily misunderstood part, and it decides whether the whole mechanism works at all. If the user clicks "Pay" once, that is one intent — so there is only one key, even if the HTTP library retries five times.

The practical consequence: the key must be generated where the intent originates (when the user clicks the button, or when the job is created) and kept unchanged across every attempt. If you generate the key just before the HTTP call, every retry gets a different key, and you will have implemented the entire mechanism without receiving any of its benefit.
The same retry, two outcomes — separated by exactly one header client server balance WITHOUT an idempotency key 1 · POST /charge amount=100 −100 2 · the response is LOST on the way back 3 · the client RETRIES (not knowing attempt 1 succeeded) −100 again Result: CHARGED TWICE. Measured under load: 14,236 rows, balance at −423,600. WITH an idempotency key 1 · POST + Idempotency-Key: order-999 −100 2 · the response is lost — but the result was ALREADY STORED under the key 3 · retry with the SAME key order-999 SELECT only · the balance is never touched Result: the ORIGINAL result is returned (same chargeId). Measured: 78,774 requests → 1 row, 1 status 201. Note that step 3 in the lower path returns 200 with the original body, NOT 409 — a 409 would make the client think it failed and possibly retry with a different key.
The client never needs to know which attempt succeeded. That is the whole point: idempotency makes the uncertainty stop mattering.

11.3 The dedup write and the business logic must be in the SAME transaction

This is the most important section of the lesson. The most intuitive implementation — and the wrong one — looks like this:

the check-then-act pattern (WRONG — not used in the lab)
// ===== WRONG: check-then-act — the classic race condition =====
const seen = await db.query(`SELECT * FROM charges WHERE idem_key = $1`, [key]);
if (seen.rows.length) return seen.rows[0].response;   // (1) check

await db.query(`UPDATE balances SET balance = balance - $1 WHERE id = 1`, [amount]);
await db.query(`INSERT INTO charges (idem_key, response) VALUES ($1, $2)`,
  [key, resp]); // (2) write

// The window between (1) and (2): TWO concurrent requests both see "not there" at (1),
// so BOTH proceed and BOTH charge. Exactly what we set out to prevent.

The fix is not to add a lock (see Lesson 10) but to let the database enforce uniqueness itself, via a UNIQUE constraint, and to do both jobs in one statement:

app/app.js — the SQL statement of the /charge handler
-- CTE: `ins` tries to write the dedup row. ON CONFLICT DO NOTHING means that if the
-- key already exists nothing is written and `ins` is EMPTY. `upd` only debits the
-- balance WHEN `ins` produced a row — so only the FIRST time has a business effect.
-- The final SELECT returns either the new result (created=true) or the STORED one.
--
-- It is ONE statement => Postgres runs it in an implicit transaction => there is NO
-- window for two concurrent requests to slip through.
WITH ins AS (
  INSERT INTO charges (idem_key, amount, response)
  VALUES ('order-999', 100, 'charge-ok-100')
  ON CONFLICT (idem_key) DO NOTHING
  RETURNING id, amount, response
), upd AS (
  UPDATE balances SET balance = balance - 100
  WHERE id = 1 AND EXISTS (SELECT 1 FROM ins)
  RETURNING balance
)
SELECT id, amount, response, 'true' AS created FROM ins
UNION ALL
SELECT c.id, c.amount, c.response, 'false' FROM charges c
WHERE c.idem_key = 'order-999' AND NOT EXISTS (SELECT 1 FROM ins);

The charges table has idem_key TEXT NOT NULL UNIQUE. That constraint is what does the real work: it turns duplicate prevention from a piece of code that can be wrong into an invariant the database will not allow to be violated.

Measured for real: 78,774 concurrent requests on one key

64 connections, 10 seconds, one and the same idem_key, against the same load without an idempotency key:

Metric WITH an idempotency key WITHOUT
Requests processed 78,774 14,172
Status codes 201 × 1 · 200 × 78,773 201 × 14,172
Rows in the database 1 14,236
Amount debited 100 1,423,600 (balance now −423,600)
Throughput 6,596 rps 1,183 rps
p50 / p99 3.41 / 72.47 ms 39.62 / 189.92 ms

The "status codes" row is the most compact evidence: out of 78,774 concurrent requests all aimed at one key, exactly one received 201 Created. Every other one received 200 with the stored result itself — the same chargeId, the same response. No request failed (0 errors).

🔬 Why the idempotent version is 5.6 times FASTER
This initially looks like an unexpected bonus of idempotency, but the real cause lies elsewhere and needs stating clearly. The non-idempotent version performs an INSERT and an UPDATE balances on every request, and all of them update the same row of the balance table. Postgres locks that row at row level, so 64 connections have to queue through a single row.

The idempotent version only writes on the first request; the remaining 78,773 only SELECT and need no write lock at all. So the throughput gap here is a consequence of eliminating lock contention, not a general property of idempotency. Under a load where requests target different keys, this gap would not appear.

But there is one general point: the non-idempotent version creates the very contention it then suffers from. Duplicate processing does not only corrupt the data — it also generates extra load exactly when the system is having network trouble, which is exactly when clients are retrying most.
⚠️ Pitfall: splitting dedup and business logic into two transactions
Even with a UNIQUE constraint, if you write the dedup row in transaction A and do the business work in transaction B, two broken windows remain.

Dedup first: A succeeds, the process dies before B runs. The key exists, so every retry is told "already processed" — but the money was never debited. The request vanishes silently.

Business logic first: B succeeds, the process dies before A runs. The money is debited but there is no dedup row, so the retry debits it again.

No ordering is safe. There is only one way: the same transaction — and the cheapest way to guarantee that is to write both jobs in one statement, as in the SQL above.
The transaction boundary — no ordering is safe once you split it in two WRONG · dedup row first, business logic second TX A: write dedup row ✓ TX B: debit the balance The key exists ⇒ every retry is told "already processed" but the money was NEVER debited — the request vanishes silently WRONG · business logic first, dedup row second TX A: debit the balance ✓ TX B: write dedup row The money is debited but there is no dedup row ⇒ the retry debits it AGAIN RIGHT · one transaction enclosing both — cheapest is to write it as ONE statement INSERT ... ON CONFLICT + UPDATE balances Either both succeed, or neither happens. The UNIQUE constraint is what enforces uniqueness, not the code. Measured: 78,774 concurrent requests on one key → exactly 1 row, exactly 1 status 201, exactly 100 debited. The "check then write" approach is not in the figure because it is worse than either WRONG option above: two concurrent requests both see "not there". And do not patch it with a lock — see Lesson 10 on why a distributed lock gives no correctness guarantee.
This is why a UNIQUE constraint is worth more than any hand-written dedup mechanism: it is an invariant enforced by the database, not a piece of code that can be raced.

11.4 HTTP semantics, and two properties that get conflated

Method Idempotent per the spec? Practical note
GET Yes (and safe — it changes no state) If your GET changes state you have violated the spec, and every cache layer will hurt you
PUT Yes With a client-generated ID, PUT makes creation naturally idempotent — no dedup table needed
DELETE Yes Deleting twice changes nothing. But returning 404 the second time can mislead the client
POST No This is why idempotency keys exist. Every POST creation endpoint needs one

Two properties get conflated constantly, and they are independent of each other:

Idempotent Commutative
Meaning Repeating does not change the result Reordering does not change the result
Example that is SET x = 5 x = x + 1 and x = x + 2
Example that is not x = x + 1 (repeating adds twice) SET x = 5 and SET x = 9 (order decides the result)

Note that the last two rows are opposites: SET is idempotent but not commutative; += 1 is commutative but not idempotent. In a distributed system you usually need both, and the way to get there is addition plus deduplication — += 1 for commutativity, the dedup table for idempotency. This also explains the hint from Lesson 9 section 9.5: balance = balance + 50 merges cleanly while SET balance = 150 does not.

⚠️ Pitfall: POST /orders without an idempotency key
This is the most common pitfall in the whole lesson, and it is almost perfectly silent. The endpoint behaves correctly in every test, behaves correctly on a stable network, and only produces duplicate orders when the network is flaky — that is, for a small fraction of users, not reproducible on demand, and usually only discovered when a customer complains or when accounting reconciles the books.

Two fixes, both good: add an idempotency key (the Idempotency-Key header is the common convention), or switch to PUT /orders/{id} with a client-generated id — at which point idempotency is a consequence of the URL design and needs no extra infrastructure at all.

11.5 Idempotency at the data layer and for consumers

You do not always need a separate dedup table. Often idempotency can live in the way you design the data itself:

Technique How it works When to use it
INSERT ... ON CONFLICT Upsert on a natural key The data has a natural key (an email, an order number from the source system) — cheapest and most certain
A natural key instead of auto-increment The primary key is the order_no issued by the source system Whenever possible. Auto-increment means every rewrite creates a new ID, so there is nothing to deduplicate on
Dedup on event_id The consumer records processed event_ids and skips ones it has seen A precondition for at-least-once message queues (Lesson 12)
A version column (optimistic) UPDATE ... WHERE version = :expected When you need both duplicate prevention and protection from mutual overwrites (Lesson 10 section 10.5)
⚡ The dedup table's TTL must exceed the queue's retry span
The dedup table has to be cleaned out, otherwise it grows without bound. But if its TTL is shorter than the message queue's redelivery span, you create a very hard-to-trace bug: a job is redelivered after the key has expired, so it is processed a second time as an entirely new job.

The comparison to make is: dedup TTL > (max retry count × longest backoff) + the time a job may sit in a dead letter queue before being replayed. For systems with a DLQ where an operator replays jobs after a few days, a TTL of a few hours is definitely not enough — and this bug only appears after an incident, which is to say at the worst possible moment.

Reproduce the measurements yourself

reproduce_measurements.sh
cd blog/sysdesign/sysdesign-lab
docker compose --profile replica up -d      # PostgreSQL is needed

psql_() { docker compose exec -T postgres psql -U lab -d lab -tAc "$1"; }
reset() { psql_ "TRUNCATE charges; UPDATE balances SET balance = 1000000 WHERE id = 1;"; }

# --- WITH an idempotency key: fire THE SAME key every time ---
reset
docker compose run --rm loadgen loadgen.js \
  --url "http://lb:8080/charge?key=order-999&amount=100&idem=1" -c 64 -d 10 -w 2 --json
psql_ "SELECT count(*) AS rows FROM charges;"               # 1
psql_ "SELECT balance FROM balances WHERE id=1;"            # 999900  (debited exactly 100)

# --- WITHOUT an idempotency key: same load, same duration ---
reset
docker compose run --rm loadgen loadgen.js \
  --url "http://lb:8080/charge?amount=100&idem=0" -c 64 -d 10 -w 2 --json
psql_ "SELECT count(*) AS rows FROM charges;"               # 14236
psql_ "SELECT balance FROM balances WHERE id=1;"            # -423600  (!)

# Look closely at the statusCodes field of the idempotent run: {"200":78773,"201":1}
# Exactly ONE request received 201 Created out of 78,774 concurrent requests.

In summary

Exactly-once delivery is impossible — the Two Generals problem says a client that receives a timeout cannot distinguish three different situations. What is possible, and what people actually want, is at-least-once + idempotent processing, and from the outside that combination looks like exactly-once.

An idempotency key must be a key for the intent, generated by the client and kept unchanged across every attempt. The server must store the complete result rather than just a "processed" flag, and return that same result when it meets a duplicate key.

The most important part is section 11.3: the dedup write and the business logic must be in the same transaction. No ordering is safe once you split them — dedup first and the request vanishes silently; business logic first and the retry charges again. And do not use the "check then write" pattern: two concurrent requests both see "not there". The cheapest and most certain approach is to let a UNIQUE constraint enforce uniqueness, in a single statement.

Measured: 78,774 concurrent requests on one key → exactly 1 row, exactly 1 status 201, exactly 100 debited, and not a single error. With idempotency off: 14,236 rows and a balance of −423,600.

Lesson 12 is where all of the above stops being a choice and becomes a requirement: message queues with at-least-once semantics. When a job can be redelivered at any moment, the consumer must be idempotent — otherwise every redelivery is another side effect.

📖 References

Download the lab source

The lab's schema, containing the charges table with its idem_key TEXT NOT NULL UNIQUE constraint — that constraint, and not any piece of code, is what enforced uniqueness throughout the measurements in section 11.3:

Download 20-schema.sql

Related lessons in this series

Lesson 10: Distributed Locks Lesson 12: Message Queues & Asynchronous Processing Back to the System Design roadmap

Comments