Engineering Atlas › System Design Fundamentals

Making retries safe with idempotency

16 min read · last reviewed 2026-10-17

Open in the interactive app: practice, quiz, mock interview →

In one minute

How it works

Why retries need it

A client sends a request and the network goes quiet. Three things could have happened: the request never arrived, the server is still working, or the server finished and the response was lost. The client cannot tell which. Its only choices are to give up, which risks losing the operation, or to retry, which risks doing it twice. Idempotency removes the second risk, so retrying becomes the right default. This is the duplicates problem shape, and it looks the same whether a payment API times out, a rider double-taps "book", or an AI agent re-runs a tool call that creates a ticket because the first call timed out.

Same state, not same response

Mathematically, f is idempotent if f(f(x)) = f(x). For APIs the useful definition is about effects: N deliveries of the same request have the same effect on server state as one. The responses may differ. DELETE /files/9 is idempotent even though the first call returns 204 and the second 404, because the file is gone either way. HTTP defines it the same way: a method is idempotent when many identical requests have the intended effect of one, and PUT, DELETE and the safe methods qualify (RFC 9110). Idempotent is also different from safe: a safe operation (GET) changes nothing, while an idempotent one may change state but only once.

Natural idempotency by design

The cheapest idempotency needs no extra machinery, because the operation is shaped to be repeatable:

Idempotency keys for everything else

Operations with no natural identity ("send this email", "charge this card") need an idempotency key: the client names the intent, and the server records the key with the outcome and replays it on a repeat. The REST API Design lesson covers the basic store; the hard parts are elsewhere. There is still no standard for this header: the IETF's Idempotency-Key draft reached version 07 in October 2025 and then expired without becoming an RFC (IETF Datatracker). Its conventions are widely copied anyway: 400 when a required key is missing, 422 when a key is reused with a different payload, and 409 while the first request is still running (draft 07). Stripe's v1 API stores the status and body of the first attempt, replays it even if it was a 500, and may forget keys once they are 24 hours old (Stripe docs). The key must be generated once per intent and reused on every retry. Two copies can arrive at once, so the first must claim the key atomically and the second must wait or get 409 Conflict. And a request that crashed halfway leaves a claimed key with no result, so the claim needs an expiring lease plus recovery points that tell a retry where to resume.

Sequence numbers for streams

When one sender sends many messages in order, a key per message is wasteful. Instead the sender numbers its messages, and the receiver remembers the last number it accepted from each sender. Anything at or below that number is a duplicate; anything above the next expected number reveals a gap. TCP has done this since 1981 with byte sequence numbers, which is why a retransmitted segment never appears twice in your socket. Kafka's idempotent producer applies the same idea to records.

Down the stack

Layer 1The contract

For an idempotent API the contract is: for a given scope (usually the caller's account) and a given key or identity, any number of deliveries within the window W produce at most one effect, and every completed delivery reports that same effect. Concurrent duplicates never both execute; one runs and the others wait or are told to retry. Reusing a key with a different request is an error, not a silent replay. After W the key may be forgotten. The client's side of the contract is to keep its retry horizon shorter than W: if a message can sit in a dead-letter queue for 7 days and then be replayed, a 24-hour window is a bug waiting to happen. Azure's guidance on idempotent consumers makes the same point, sizing the window from delivery attempts, lock timeout and message TTL, plus manual resubmissions from dead-letter queues (Azure Architecture Center).

Layer 2The mechanism

Kafka's idempotent producer shows the sequence-number approach in a production system. enable.idempotence=true was meant to be the default from Kafka 3.0, but a validation bug delayed it to 3.0.1, 3.1.1 and 3.2.0 (KAFKA-13598), and it still turns itself off without warning if you set a conflicting acks, retries or in-flight limit and leave idempotence unset (Kafka producer configs). The broker assigns each producer a producer ID and an epoch. Each batch the producer sends to a partition carries the producer ID, epoch and a base sequence number. For each producer and partition, the broker keeps the last sequence number and metadata for the 5 most recent batches. A batch whose sequence is the next expected one is appended. A batch that matches one of the cached 5 is a duplicate: the broker acknowledges it with the original offset and writes nothing. A batch that skips ahead is rejected with OutOfOrderSequenceException. Hence max.in.flight.requests.per.connection must be at most 5. The epoch fences zombies: a restarted producer with the same transactional.id gets a higher epoch, and the broker rejects writes from the old one.

In a relational database the same guarantees come from constraints. INSERT INTO idempotency_keys (account_id, key, request_hash, status) VALUES (...) ON CONFLICT DO NOTHING RETURNING key claims a key atomically: if no row comes back, someone else owns it. The effect and the key's final status must commit in one transaction, otherwise a crash between them leaves a key that says "done" for an effect that never happened, or the reverse. SQS FIFO queues offer the same at the queue level with MessageDeduplicationId and a fixed 5-minute deduplication interval.

Layer 3The machine

Deduplication state costs memory or disk in proportion to rate times window. At 10,000 requests per second with a 24-hour window, the store holds 864 million keys; at about 200 bytes per row with its index entry, that is roughly 170 GB. Each new key is also a unique-index insert in a B-tree and a write-ahead log record that must reach disk with fsync before the response, about 0.05 to 2 ms depending on storage. Sequence numbers cost almost nothing by comparison: Kafka keeps a fixed handful of entries per producer and partition, so its dedupe state does not grow with traffic. Windows exist at the bottom of the stack too. TCP's sequence numbers are 32 bits, so at 10 Gbit/s the 4 GB sequence space wraps in about 3.4 seconds, faster than an old delayed segment can be ruled out. TCP therefore adds timestamps (PAWS, RFC 7323) to reject an old segment whose sequence number has come round again.

Why it's built this way

  1. Networks lose both requests and responses, and the sender cannot tell which, so it must be allowed to retry.
  2. Retrying a non-idempotent operation duplicates its effect, so each intent needs an identity the receiver can recognise.
  3. Remembering identities costs storage that grows with rate times time, so every scheme keeps a bounded window.
  4. A bounded window only works if retries stop before it closes, so retry policies, queues and replays must be designed together with the window.
  5. Where the sender is a single ordered stream, a counter replaces a set of keys, which is why TCP and Kafka use sequence numbers and HTTP APIs use keys.

Diagram

sequenceDiagram
  participant P as Producer (id 42, epoch 3)
  participant B as Broker partition
  P->>B: batch seq=7
  B-->>P: ack lost (timeout)
  P->>B: retry batch seq=7
  Note over B: seq 7 in last 5 batches
  B-->>P: duplicate, original offset 1001
  P->>B: batch seq=9
  B-->>P: OutOfOrderSequence (expected 8)
  P->>B: batch seq=8 (epoch 2, zombie)
  B-->>P: fenced, epoch too old

Worked example

A notifications service sends 2,000 push and email messages per second on behalf of other services. Callers use a 2-second timeout and retry up to 3 times. In a normal hour 0.5% of calls time out; during a database failover 10% do.

Without idempotency, most timeouts happen after the message was already sent, so 0.5% of 2,000 is 10 duplicate notifications per second, about 860,000 a day. During a failover it is 200 per second, and every user in that window gets the same password-reset email two or three times.

With idempotency keys derived from the business event, for example password-reset:<user_id>:<token_id>, even a caller that crashes and restarts reuses the same key. The client retry horizon is about 15 seconds, but upstream services can replay their queues for up to 3 days, so the window is set to 4 days. Storage is 2,000 × 345,600 seconds, about 690 million keys; at 150 bytes each that is about 100 GB in a key-value store with TTL expiry, which is cheap next to the support cost of duplicate messages.

The email provider is the remaining gap: if the service crashes after the provider accepted the message but before marking the key done, the retry sends again. Passing the same key to the provider's own idempotency header closes it, because the provider then deduplicates too. Each hop has to carry the identity forward.

In the wild

Trade-offs

ApproachStrengthsWeaknessesChoose when
Natural (set, PUT, delete by ID)No extra state, trivially correctOnly works for operations that can be expressed as target stateUpdates and deletes of known resources
Client-generated IDs plus unique constraintDedupe lives in the business table, no expiryClient must generate IDs; the constraint must cover the real intentCreating orders, payments, records
Conditional writes (version, ETag)Also prevents lost updates between different writersRepeat gets a conflict, not the original responseRead-modify-write updates
Idempotency keys with stored resultsWorks for any operation, replays exact responseStorage grows with rate times window; in-flight and crash handlingCharges, sends, third-party calls
Per-sender sequence numbersConstant state, detects gaps as well as duplicatesNeeds one ordered sender per sequenceStreams, replication, producer to broker

Pitfalls in production

Leadership lens

Check yourself

  1. DELETE /items/5 returns 204 the first time and 404 the second. Is it idempotent?

    Show answer

    Yes. Idempotency is about the effect on state, and after one or two calls the item is gone. Clients should treat 404 on a retried delete as success.

  2. Why does Kafka limit an idempotent producer to 5 in-flight requests per connection?

    Show answer

    The broker remembers only the last 5 batches per producer and partition. With more in flight, a retried batch could fall outside that cache and be rejected instead of being recognised as a duplicate.

  3. A consumer deduplicates by message ID in a 24-hour Redis set. What scenario still produces duplicates?

    Show answer

    Any redelivery after 24 hours, such as a dead-letter queue replay, plus a crash between performing the effect and writing the ID, because Redis and the effect are not in one transaction.

  4. When is a version-conditioned update better than an idempotency key?

    Show answer

    For read-modify-write updates where several writers may race: the version check deduplicates retries and also prevents one writer silently overwriting another, without a separate key store.

Sources and further study

Primary

Systems docs

Books

Other curricula

Watch

Free resources

Open in the interactive app: practice, quiz, mock interview →