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 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.
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.
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 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.
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:
// ===== 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:
-- 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).
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.
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.
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.
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 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
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
-
Stripe — Idempotent requests: the
Idempotency-Keyheader convention section 11.2 is based on, including their TTL policy (24 hours) - IETF RFC 9110 §9.2.2 — the official definition of an "idempotent method", the source of the table in section 11.4
- Wikipedia — the Two Generals problem, the theoretical basis for "exactly-once is impossible" in section 11.1
-
PostgreSQL —
INSERT ... ON CONFLICT, the mechanism behind the statement in section 11.3 -
PostgreSQL — data-modifying CTEs (
WITH ... INSERT/UPDATE): why the whole CTE runs against one snapshot, which is what makes the pattern in 11.3 safe - Confluent — how Kafka does "exactly-once": read it to see precisely the scope in which it applies, exactly the point of the pitfall in section 11.1
- Brandur Leach — Implementing Stripe-like Idempotency Keys in Postgres: the extended version of section 11.3 for business logic that will not fit into one statement
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:
Comments