Skip to content

Idempotency

Making a retry safe when a timeout leaves you unsure — idempotency keys, the race a naive implementation misses, and storing responses.

Intermediate18 minUpdated 28 September 2026

A client sends POST /transfers for £50. The server receives it, moves the money, and then the connection drops before the response gets back.

The client sees a timeout. It has no way to distinguish "the request never arrived" from "the request succeeded and the reply was lost." Those two states are indistinguishable from outside, and they demand opposite responses: retry, or absolutely do not retry.

This ambiguity is not an edge case. It is the normal condition of any network, and it is why retry logic — the thing that makes distributed systems reliable — is also the thing that double-charges customers.

Idempotency is how you make the retry safe, so the client can stop having to know.

The property

An operation is idempotent if performing it many times leaves the same state as performing it once.

f(f(x)) = f(x)

Setting a light switch to ON is idempotent; flipping it is not. Setting a thermostat to 20°C is idempotent; raising it by two degrees is not. The distinction is almost always declaring an end state versus applying a delta.

That framing is the practical tool. UPDATE users SET age = 20 is naturally idempotent. UPDATE accounts SET balance = balance - 50 is not, and no amount of retry logic will make it so.

What HTTP already promises

The verbs carry idempotency guarantees, which is why intermediaries and clients feel free to retry some of them automatically.

MethodIdempotentSafeWhy
GETYesYesReads nothing changes
PUTYesNoReplaces with a stated value
DELETEYesNoEnd state is "gone" either way
PATCHDependsNoset status=paid yes; increment views no
POSTNoNoEach call creates a new resource

Safe and idempotent are different claims. DELETE is idempotent but not safe: it changes state, it just does not change it further on repetition. Only GET and HEAD promise no state change at all.

DELETE is worth a moment. The second call finds nothing to delete and may return 404 where the first returned 204. The status codes differ; the resulting state does not. Idempotency is a claim about state, not about responses.

Making POST idempotent with a key

POST is the problem case, and the fix is an idempotency key: a unique value the client generates once per logical operation and reuses on every retry of it.

The client generates it, not the server. This is the whole design. If the server generated it, the client would need a round trip to obtain one — and that round trip could time out, putting you back where you started.

// Generated ONCE when the user taps Pay, reused by every retry
const key = crypto.randomUUID();

await fetch('/api/payments', {
  method: 'POST',
  headers: { 'Idempotency-Key': key },
  body: JSON.stringify({ amount: 50, currency: 'GBP' }),
});

The server records the key alongside the result of the work, and any later request bearing the same key returns that stored result instead of doing the work again.

The race that a naive implementation misses

Conceptual diagraminsert wonkey exists, completekey exists, in progressRequest with keyInsert key, unique constraintDo the workStore response, mark completeReturn resultReplay stored response409: ask again shortly
Diagram, as text
  • Request with key leads to Insert key, unique constraint
  • Insert key, unique constraint leads to Do the work (insert won)
  • Do the work leads to Store response, mark complete
  • Store response, mark complete leads to Return result
  • Insert key, unique constraint leads to Replay stored response (key exists, complete)
  • Insert key, unique constraint leads to 409: ask again shortly (key exists, in progress)

The obvious server-side shape is: look up the key, and if it is absent, do the work and store it. That is a check-then-act, and it has a window.

An impatient user taps twice, or a retry fires while the original is still in flight. Both requests look up the key, both find nothing, both charge the card. The idempotency layer did nothing at all.

async function createPayment(key, body) {
  const claimed = await redis.set(`idem:${key}`, 'IN_PROGRESS', 'NX', 'EX', 86400);
  if (!claimed) {
    const stored = await redis.get(`idem:${key}`);
    if (stored === 'IN_PROGRESS') throw new Conflict();   // still running
    return JSON.parse(stored);                            // replay the result
  }

  const result = await chargeCard(body);
  await redis.set(`idem:${key}`, JSON.stringify(result), 'EX', 86400);
  return result;
}

Store the response, not just a flag

Recording "this key was used" is not enough. The retrying client needs an answer — the payment id, the transfer reference — and it needs the same answer the first attempt would have given.

So store the serialised response body and status against the key, and replay it verbatim. A retry that returns 200 with the original payload is indistinguishable, from the client's point of view, from having got through the first time. That is the goal.

Two details make this behave well in practice:

Scope the key. Store it per endpoint and per authenticated caller. A key is unique to one client's one operation; two clients that happen to generate the same UUID must not collide, and the same key on a different endpoint means something has gone wrong.

Fingerprint the request. Save a hash of the body alongside the key. If the same key arrives with a different body, that is a client bug, not a retry — reject it with 422 rather than silently returning the old result.

Keys need a TTL because storing them forever is unbounded growth. Retries happen within seconds to minutes, so a window measured in hours to a couple of days is comfortable. The TTL is how long a duplicate is still recognised as a duplicate, so make it longer than your longest retry schedule.

Where else this shows up

The same idea appears wherever delivery is at-least-once. Message consumers deduplicate on message id for exactly this reason: the broker cannot promise a message arrives once, so the consumer makes arriving twice harmless.

Idempotency is what turns an unreliable transport into a reliable operation, and it is cheaper than any attempt to make the transport itself exact.

Check your understanding

Common mistakes

Check-then-act. Reading the key and then writing it leaves a window in which two concurrent requests both proceed. Claim it atomically.

Recording the key after the work instead of before. A crash between the side effect and the write leaves no record, and the retry does it all again.

Storing only a flag. The retrying client needs the original response, not a bare acknowledgement that something happened once.

Letting the server generate the key. Fetching a key needs a request that can time out, which reintroduces the exact ambiguity you were removing.

Not scoping the key to the caller and endpoint. A global key namespace lets one client's key shadow another's.

Ignoring a changed request body. The same key with different contents is a bug in the client. Reject it rather than replaying an unrelated result.

A TTL shorter than the retry window. The key expires, the retry arrives, and the work runs a second time.

Practice

Design the idempotency-key lifecycle for a payments API: how the key is generated, how long it is kept, what happens when a retry arrives mid-flight, and what happens after expiry.

Write your own answer first, on paper or in a note. Then compare.

Show a hint

Every stage has a failure mode. Name the one you are choosing to accept.

Show a strong answer

Generation — by the client, before the first attempt. The key must be stable across retries, so it cannot be generated server-side or per attempt. A client generating one per logical operation (one checkout) is what makes a retry recognisable. Reject requests without a key rather than inventing one.

Storage — claim first, then act. Insert the key with a uniqueness constraint and status in_progress before touching the payment provider. This makes the claim atomic and closes the concurrent-duplicate race.

A retry arriving mid-flight finds status in_progress. Do not proceed, and do not return success. Return 409 Conflict or a 202 meaning "this is being processed, ask again". The client polls. The alternative — blocking the retry until the original finishes — ties up a connection for the duration and turns one slow request into two.

On completion, store the full response body and final status, so any later retry replays the exact original answer.

Retention — long enough to outlive any client retry policy. 24 hours is the common floor; for payments, 7 days is safer because a client's manual retry, or a mobile app resumed the next day, still lands inside the window. Retention is your protection window; shortening it to save storage shortens the guarantee.

After expiry, the same key is treated as new and the effect repeats. This is the failure mode you are accepting, so state it in the API documentation rather than leaving clients to discover it.

One more case worth handling: the same key arriving with a different request body. That is a client bug — reject with an error rather than returning the original response, or you will silently swallow a genuinely different payment.

A colleague says 'we use PUT instead of POST, so our API is idempotent'. Explain what is right and what is missing.

Write your own answer first, on paper or in a note. Then compare.

Show a hint

The method describes what the client may assume. It does not describe what your handler does.

Show a strong answer

What is right: the HTTP method carries a promise to intermediaries and clients. PUT is defined as idempotent, so browsers, proxies and client libraries may retry it safely after a timeout without asking. Choosing PUT for a replace-this-resource operation is correct and useful.

What is missing: the method is a declaration, not an implementation. Nothing enforces it. A PUT handler that appends a row, increments a counter, sends an email or charges a card is not idempotent, and the method label makes it more dangerous rather than less — because now the client's HTTP stack will retry it automatically, confident that doing so is safe.

Two specific gaps in the colleague's reasoning:

  • Not every operation can be expressed as PUT. "Charge this card £50" is not a replacement of a known resource at a known URL; it creates something new. Forcing it into PUT usually means inventing a client-supplied identifier — which is really an idempotency key wearing a different hat, and it should be designed as one.
  • Idempotency must survive concurrency, not just repetition. Two identical PUTs arriving simultaneously can still interleave into a lost update unless the write is conditional — on a version, an ETag, or a uniqueness constraint. The method says nothing about that.

The correct statement is: use the method that matches the semantics, and implement the property. The first tells the world what to expect; the second makes it true.

Key takeaways

  • After a timeout a client cannot tell whether the request succeeded, so retries are unavoidable and duplicates are guaranteed.
  • An operation is idempotent when repeating it leaves the same state — in practice, declare an end state rather than apply a delta.
  • GET, PUT and DELETE are idempotent, POST is not, and idempotent is not the same as safe: DELETE changes state, it just does not change it further.
  • Make POST idempotent with a client-generated idempotency key, reused across every retry of one logical operation.
  • Claim the key atomically before doing the work — SET NX or a unique constraint. Check-then-act is the bug that makes the whole mechanism useless.
  • Store the response, not a flag, and replay it verbatim so a retry is indistinguishable from success.
  • Scope keys per caller and endpoint, fingerprint the body to catch misuse, and set a TTL longer than your longest retry schedule.
  • The same pattern makes message consumers safe under at-least-once delivery.

Further reading

  • RFC 9110, HTTP Semantics, section 9.2.2 — the formal definition of idempotent methods.
  • Pat Helland, "Idempotence Is Not a Medical Condition" (ACM Queue, 2012) — the standard treatment.
  • Pat Helland, "Life Beyond Distributed Transactions: An Apostate's Opinion" (CIDR 2007) — why retries force idempotent design.
  • Bernstein, Hadzilacos and Goodman, Concurrency Control and Recovery in Database Systems (Addison-Wesley, 1987) — conditional writes and lost updates.
  • Michael Nygard, Release It!, second edition (Pragmatic Bookshelf, 2018) — retries and the outages they cause.

Check yourself

3 questions in this lesson. Answer each once to complete it.

Practice this

Next lessonDistributed Transactions (SAGA Pattern)
Lessons in this track