Concepts

Measuring systems

Foundations

Idempotency

An operation is idempotent when doing it twice has the same effect as doing it once. It is what makes retries safe — and retries happen whether you planned them or not.

Every retry that reaches a server twice is a duplicate unless the server can recognise it. Recognising it costs a key and a lookup on every write; not recognising it costs a double charge.

idempotency keyretriesexactly-onceduplicates

Try it

Move the dials — the sentence under the picture changes.
Request carries
Charges made (1031)intended 1000 · duplicates 31Customers charged more than once31 of 1000
1000 customers meant to pay once. 31 were charged twice or more — every retry after a lost reply was a fresh charge, because the server had no way to tell it was the same request.

In plain words

An operation is idempotent if doing it twice has the same effect as doing it once. Pressing a lift button: idempotent. Pressing "buy": not, unless the shop is careful. The word matters because in a distributed system requests will be delivered twice — a reply gets lost, a client retries, a queue redelivers — and whether that is harmless or a double charge depends entirely on whether the operation was designed to be idempotent.

Which operations are already safe

OperationTwice = once?Why
GET /orders/42YesReading changes nothing
PUT /users/42 {name: "Ana"}YesSetting a value to Ana twice leaves it Ana
DELETE /orders/42YesDeleted twice is still deleted (even if the second returns 404)
POST /orders {…}NoCreates a new order each time
POST /accounts/42/debit {amount: 50}NoSubtracts 50 each time
UPDATE stock SET qty = qty − 1NoRelative change; each run moves it again
UPDATE stock SET qty = 7YesAbsolute value

The idempotency key

The client generates a unique id for the intent — "this particular payment" — and sends it with every attempt. The server stores the id with the result. A repeat with a known id gets the stored result and does no work.

AppPayments APIStorekey = 7f3a… (generated once, for this payment)POST /payments Idempotency-Key: 7f3a… $50INSERT key 7f3a… (status: in progress)charge the card → ok, charge id ch_991UPDATE key 7f3a… → result {ch_991}200 {ch_991}lostretry: POST /payments Idempotency-Key: 7f3a…INSERT key 7f3a… → already existsresult {ch_991}200 {ch_991} — same answer, no second charge
The key turns 'a request that looks the same' into 'the same request'. The second attempt is answered from memory.
Idempotency at the server, with the race handledTypeScript
app.post("/payments", async (req, res) => {
  const key = req.header("Idempotency-Key");
  if (!key) return res.status(400).send("Idempotency-Key required");

  // Claim the key atomically. If it already exists, someone (maybe us, a
  // moment ago) is handling or has handled this request.
  const claimed = await db.query(
    "INSERT INTO idempotency (key, status) VALUES ($1, 'pending') ON CONFLICT DO NOTHING RETURNING key",
    [key],
  );
  if (claimed.rowCount === 0) {
    const prior = await db.query("SELECT status, response FROM idempotency WHERE key = $1", [key]);
    if (prior.rows[0].status === "done") return res.status(200).json(prior.rows[0].response);
    return res.status(409).send("in progress — retry shortly");   // a concurrent duplicate
  }

  const charge = await cardProvider.charge(req.body.amount, { idempotency_key: key }); // pass it on
  await db.query("UPDATE idempotency SET status = 'done', response = $2 WHERE key = $1", [key, charge]);
  res.status(200).json(charge);
});

Three details that matter:

  1. Claim before doing the work

    Insert the key first, atomically. Two retries arriving at once must not both charge; the second must see "pending" and wait.

  2. Store the whole response

    The retry should get exactly what the first attempt would have returned — status code and body — so the client cannot tell the difference.

  3. Pass the key downstream

    Your payment provider has the same problem with you. Give them the same key (Stripe, Adyen and most others accept one) so your retry to them is safe too.

Where the duplicates come from

  • Client retries on a timeout — the case above.
  • Queues with at-least-once delivery: a consumer that crashes after the work and before the ack gets the message again. See message queues.
  • Gateways and proxies that retry on your behalf, invisibly.
  • Users double-clicking, or pressing back and resubmitting.
  • Your own retry code at two layers at once — see retries and backoff.

Every one of these is handled by the same key. That is why the key is worth the lookup: it is one mechanism for five failure modes.

Where it goes wrong

  • Generating the key on the server. Then every attempt gets a fresh key and nothing is deduplicated. The client must generate it, once, for the intent.
  • Keys that live forever. A key table that never expires is a table that grows forever. Keep keys for as long as a retry could plausibly arrive — 24 hours is common — and expire them.
  • Reusing a key for a different request. Same key, different amount: the server returns the old result. Some APIs reject a key reused with a different body; do that.
  • "Idempotent" but not atomic. Check-then-act without a lock lets two concurrent duplicates both pass the check. The claim must be one atomic operation.

Take this with you

  • The one idea: requests will be delivered twice. Make the operation absolute where you can, and give it a client-generated key where you cannot.
  • In an interview, every retry, queue and webhook in your design needs a sentence about idempotency. Give it.
  • At work, find the POST that moves money or sends a message, and ask what happens when the reply is lost. If the answer is "it happens twice", that is the bug to fix first.