Designing for Idempotency & Exactly-Once

A system design interview guide to making retried operations safe, so that a request that lands twice never charges a card twice, places two orders, or otherwise double-applies a side effect.

Student edition · start with the guarantee

Make repeated API attempts produce one logical effect despite concurrent retries and uncertain remote outcomes.

Correctness contract: Keys are caller/endpoint scoped and payload-bound. Effect and outcome commit atomically locally, or downstream dedup protects the same retained operation.

Basic HTTP, databases, and programming are assumed. Specialized terms are explained below. Named products motivate the problem; this is an educational design, not a claim about their current internals. Capacity numbers are illustrative decimal payload estimates, not benchmarks. Add indexes, protocol overhead, replicas, headroom, and failure capacity before provisioning.

A lost response leaves the caller uncertain whether an operation committed. The server/provider may know, but the caller must retry or query using the same logical identity. A durable, scoped, payload-bound key plus an atomic local effect/result or downstream idempotency contract protects against repeated effects within its stated retention/failure model.

Contents

  1. Requirements & Clarifying Questions
  2. Back-of-Envelope Estimation
  3. API & Data Model
  4. Why Retries Are Unavoidable
  5. How Retries Create Duplicates
  6. The Idempotency Key Pattern
  7. The Dedup Store
  8. Concurrent Retries & Locking
  9. Delivery vs Effect
  10. The Transactional Outbox
  11. Naturally Idempotent Design
  12. Failure Modes
  13. Tradeoffs & Alternatives
  14. A 45-Minute Interview Walkthrough
  15. Common Mistakes
  16. Summary
  17. Check Yourself
  18. Key Terms
  19. Further Reading
  20. Concepts to understand first
  21. Worked example
  22. Runnable Python model

Concepts to understand first

Each definition includes a concrete example and the condition under which the mechanism is useful. You can read this guide without opening another guide.

Idempotency keys

A client-generated identity for one logical operation. The server binds it to caller scope, endpoint, request fingerprint, state, and result. A retry uses the same key and reuses the original outcome rather than repeating the effect.

Example. Payment p42 times out after the provider charged it. Retrying with p42 lets the provider return the same charge instead of creating a new one.

Limit. A unique local claim is insufficient if the business effect and saved result can diverge. Keep unknown outcomes pending, use downstream idempotency, and state the retention/retry horizon.

Optimistic concurrency and compare-and-set

Update state only if the expected version or status still matches, in the same atomic operation that changes it. A failed comparison means a conflicting operation won; reread or return a conflict.

Example. UPDATE seats SET status='held', version=version+1 WHERE id=? AND status='available' allows only one concurrent claimant to update the row.

Limit. Application-level GET then unconditional SET is not equivalent. Include acquisition identity to reject stale callbacks, and couple related invariants in a transaction.

Transactional outbox

A business transaction writes its state change and an event row to the same database. A relay publishes committed event rows later, retrying until the broker durably accepts them.

Example. An order and its OrderCreated outbox row commit together. A relay crash after publish but before marking sent publishes the same event ID again on restart.

Limit. The relay is at-least-once, not magically exactly-once. Consumers dedup transactionally; preserve per-entity sequence when order matters. CDC is change data capture: reading committed database changes.

Sagas and compensation

A durable workflow of local transactions with corrective actions for failures. Compensation is a new business operation, not a rollback of history. The workflow exposes intermediate states and must recover after crashes.

Example. Reserve a seat, authorize payment, confirm booking, then capture. A declined authorization releases the seat; an uncertain charge stays pending until reconciled.

Limit. Timeout does not mean the remote effect failed. Compensation can fail too and needs retries/escalation. A saga does not provide isolation or guarantee every external effect is reversible.

Leases

Time-limited permission issued by an authority. Expiry lets new work proceed after a client stops renewing; it does not prove that the previous holder is dead or unable to act.

Example. A 30-second lease expires while A is paused for 45 seconds. B can acquire a new lease, but A can still wake up and send a delayed write.

Limit. Heartbeats improve recovery time, not safety by themselves. Compare-and-renew/delete must verify a unique acquisition token atomically, and protected effects need fencing or equivalent serialization.

Offsets and checkpoints

An offset is a position in one log partition. A checkpoint is a recoverable, consistent record of input positions and processing state. On restart, state must correspond exactly to the input that is considered processed.

Example. If a batch increments a total and crashes before saving its offset, replay increments twice. Commit the total, dedup state, and partition position together, or use a compatible transactional/idempotent sink.

Limit. Saving an offset after a write avoids that specific loss window but can still duplicate; framework checkpoints alone do not make arbitrary external effects transactional.

1. Requirements & Clarifying Questions

An interviewer may say "make the payments API safe to retry." Pin down the scope first:

FunctionalNon-functional
Accept an Idempotency-Key header on every unsafe requestExactly-once effect under retries and concurrent duplicates
First request runs; repeats get the stored responseAdds only a few ms to each request
Same key with a different body is rejectedKeys survive server crashes and work across the whole fleet
Keys expire after a retention windowStorage stays bounded
Intuition

An elevator call button is idempotent. Press it once or ten times, and one elevator comes. An idempotency key turns a "buy" button into an elevator button. The server remembers the first press and ignores the rest.

2. Back-of-Envelope Estimation

Take a payments API at 5K unsafe requests/s with a 24-hour key TTL.

StepWorkingResult
Keys per day5K/s × 86,400 s432M keys
Store size, full responses432M × 2 KB864 GB
Store size, hash + status only432M × 200 B86.4 GB
Extra DB writes1 insert + 1 update per request10K writes/s
What the numbers tell you

Storing full response bodies costs ten times more than a hash and a small status. Many systems store only the status code and a pointer to the created object, and rebuild the response on replay. At 10K extra writes per second, keep the key table in the same database as the business data if you want one transaction for both. Otherwise use a fast sharded store with TTL.

3. API & Data Model

POST /v1/payments
Idempotency-Key: 9f1c2e7a-5b4d-4c1e-8a0f-2d6b3c9e1a77
{ "amount": 5000, "currency": "USD", "card": "tok_123" }

200 OK             first run, or a replay of a finished run
409 Conflict       same key still running: retry after a moment
422 Unprocessable  same key, different body: a client bug
CREATE TABLE idempotency_keys (
  key          TEXT PRIMARY KEY,         -- the unique index that claims a key
  request_hash TEXT NOT NULL,            -- fingerprint of the body
  status       TEXT NOT NULL,            -- IN_PROGRESS | COMPLETED
  response     BYTEA,                    -- status code + body to replay
  locked_until TIMESTAMP,                -- lease on IN_PROGRESS
  expires_at   TIMESTAMP NOT NULL        -- TTL, reaped by a sweeper
);

Scope the key to the caller. Store it as (account_id, key), so one customer's keys can never collide with another's.

4. Why Retries Are Unavoidable

The root cause of every duplicate problem is that a network call has three possible outcomes, not two. It can clearly succeed, it can clearly fail, or — the troublesome third case — it can leave the caller with no answer at all. A request may have reached the server and been fully processed, but the response was lost on the way back. From the caller's chair, a lost response is indistinguishable from a request that never arrived.

Given that ambiguity, the caller has only two choices, and both are bad if the operation is not idempotent. It can give up, risking that a legitimate operation silently never happened. Or it can retry, risking that an operation which already succeeded gets applied a second time. Real systems almost always choose to retry, because losing a customer's order is worse than the rare double, and because timeouts are common enough that a no-retry policy would make the system feel broken. Once you accept that retries are a permanent feature of the landscape, the design question becomes: how do we make a second, third, or tenth identical request harmless?

A clean framing for an interview: idempotency is not a feature you add for tidiness, it is the price of admission for any system that does retries — and every reliable distributed system does retries. You are not deciding whether duplicates can happen, only where you absorb them.

5. How Retries Create Duplicates

It helps to make the failure concrete. Imagine a checkout endpoint that charges a card and creates an order. The client calls it, the server charges the card and writes the order, and then the connection drops before the success response gets back. The client, seeing only a timeout, retries. The server happily charges the card again and creates a second order. The customer is now out double the money and has two shipments on the way — a failure that is both expensive and deeply eroding of trust.

The same shape appears anywhere a write has a side effect that the world can observe: sending money, placing an order, sending an email, incrementing a counter, provisioning a resource. Note that pure reads are naturally safe — fetching a record twice changes nothing — so the entire problem is confined to writes that do something. The job of an idempotency design is to let those writes be retried as freely as reads.

OperationNaively retriedWhat the user sees
Charge a cardCharged twiceDouble the amount debited.
Place an orderTwo orders createdTwo shipments, two invoices.
Transfer fundsMoney moved twiceAccount balances wrong.
Send an emailDuplicate emailsAnnoyance, lost trust.

6. The Idempotency Key Pattern

The standard solution puts the client in charge of identity. Before sending a request that has a side effect, the client generates a single unique value — an idempotency key — that names this one logical request, and attaches it (typically as a header such as Idempotency-Key). The crucial property is that the same key is reused across all retries of the same logical request, but a brand-new logical request gets a brand-new key. A UUID generated once per "user clicked Pay" is the canonical choice.

On the server, the key turns "have I done this already?" from an unanswerable question into a simple lookup. The server records, for each key, the outcome of the request. The first time it sees a key, it executes the operation and stores the result against the key. On any later request bearing the same key, it skips execution entirely and returns the stored result. The client cannot tell whether its request was the one that did the work or a replay — it just gets the same answer, which is exactly what it wanted.

Idempotent request flow
The first request carries an idempotency key; the server checks its store, executes the operation once, stores the result against the key, and returns it. When the network forces a retry with the same key, the server finds the stored result and returns it without re-executing the side effect.
function handle(request, idempotency_key):
  existing = store.get(idempotency_key)
  if existing and existing.status == "COMPLETED":
    return existing.response          # replay: do not re-execute
  result = do_the_work(request)       # the side effect, run once
  store.put(idempotency_key, status="COMPLETED", response=result)
  return result

The pattern shifts a small responsibility to the client — it must generate and reuse a key — in exchange for a large guarantee from the server. It also keeps the contract explicit: a key represents one logical intent, so reusing a key for a genuinely different request would be a client bug, not the server's problem to disambiguate.

7. The Dedup Store

Store caller/tenant scope, endpoint, key, canonical operation fingerprint, status, result, and retention. One key represents one logical request; an untrusted global string is not a safe caller scope. Stable response replay may require saving the original body/status rather than rebuilding from mutable data.

States include in-progress, unknown/reconciling, completed, and confirmed-failed-with-no-effect. Timeout/5xx does not prove no effect, so deleting the claim can double-charge. Expired leases do not prove the old worker stopped. Resolve via local atomic transaction, provider idempotency, or a status/reconciliation protocol.

A TTL bounds protection; late retries can run again. Size it from clients, queues, provider windows, and financial policy. HTTP 409/422 and failure caching are API choices; providers may cache 500 outcomes. Follow the actual provider contract.

8. Concurrent Retries & Locking

For a local effect, claim the key, validate fingerprint, apply business state, and save outcome in one transaction with uniqueness and suitable isolation. Request dedup complements rather than replaces business constraints.

begin transaction
  insert operation(scope, key, fingerprint)  # UNIQUE(scope,key)
  apply_local_effect()                       # same DB transaction
  save_original_response()
commit

Remote effects need durable pending work and the same downstream identity on every attempt. Unknown outcomes stay reconciling, not automatically deleted. Worker takeover must be versioned/fenced and safe to repeat. Canonicalize operation-defining fields including principal/path; arbitrary JSON formatting is not semantic canonicalization. Bound retries and escalate uncertainty when the provider cannot deduplicate/report status.

9. Delivery vs Effect

Specify the observation boundary. A network can retransmit bytes/requests while TCP or an application exposes a deduplicated logical stream. 'Exactly-once delivery is impossible' is too broad as a statement about every API.

Across independent systems, an uncertain ACK makes retry ambiguous. At-least-once processing plus durable transactional dedup can expose exactly-once effects within a stated failure/retention model. Dedup alone, finite retries, or a lossy source does not prove every intent happens exactly once. Separate at-most-once safety from eventual completion/liveness.

10. The Transactional Outbox

Idempotency on the receiver handles duplicates, but there is a matching problem on the sender: how do you reliably publish an event exactly when a database change commits, with no chance of the two diverging? The tempting approach — write to the database, then publish to the message broker — has a fatal gap. If the process crashes between the two steps, you have committed the data but never announced it, and downstream systems never learn. Publish first and the opposite happens: you announce a change that the database then fails to commit.

The transactional outbox closes the gap by making the event part of the same database transaction as the business write. In one atomic transaction you write the business row and insert a row into an outbox table describing the event. Because they commit together, you can never have one without the other. A separate relay process then polls the outbox, publishes each pending event to the broker, and marks it sent. If the relay crashes mid-publish, it simply republishes on restart — which is harmless precisely because the consumers are idempotent on the event id.

Transactional outbox pattern
A single database transaction writes both the business row and an outbox event row, so they commit together or not at all. A relay process polls the outbox, publishes each event to the broker, and marks it sent only after the publish succeeds. A crash between publish and mark means the event is sent again, so delivery is at least once and consumers must dedup on the event id.
# inside one DB transaction
begin()
  orders.insert(order)                # the business change
  outbox.insert(event="order_placed", payload=order, status="PENDING")
commit()                              # both rows, or neither

# relay, running separately
for event in outbox.where(status="PENDING"):
  broker.publish(event)               # at-least-once; consumers dedup
  outbox.mark(event.id, status="SENT")

Polling is not the only relay. With change data capture (CDC), a tool such as Debezium tails the database's write-ahead log and publishes each committed outbox row. That avoids polling load and keeps commit order. Either way, send the event id with the message so consumers can dedup.

11. Naturally Idempotent Design

The cheapest idempotency is the kind you do not have to bolt on, because the operation is idempotent by construction. Whenever you can express a write so that applying it twice yields the same end state as applying it once, you sidestep the whole machinery of keys and dedup stores. Several common shapes have this property.

# naturally idempotent: set absolute state, not a delta
profile.set_email("a@b.com")          # safe to run twice

# conditional write: retry becomes a no-op
update order set status="PAID"
  where id = 42 and status = "PENDING" # second run matches nothing
Common mistake

Using set-to-state for money, such as set_balance(500). It is idempotent, but it is a lost-update bug. If a deposit lands between your read and your write, your "set" wipes it out. For money, record each movement as its own row with a unique id, and use a conditional write on a version column for anything that must be "set."

A good instinct in design reviews: before reaching for idempotency keys, ask whether the operation can simply be reshaped to be idempotent on its own. Set-to-state, conditional writes, and content-derived ids remove duplicates at the source instead of cleaning them up after the fact.

12. Failure Modes

What failsWhat happensHow to handle it
Crash after the side effect, before COMPLETEDKey stuck IN_PROGRESS; a blind retry repeats the effectCommit work and status together, or pass the key downstream
Two duplicates arrive togetherBoth could run the workUnique constraint on the key; loser gets 409 or the replay
Downstream call times outEffect unknownRetry downstream with the same key; do not cache the timeout
Retry arrives after the TTLTreated as new; runs twicePick a TTL longer than any retry window; add a natural key for money
Dedup store is downCannot check keysFail closed for money (return 503); fail open only for harmless ops
Outbox relay crashes after publishEvent sent twiceConsumers dedup on the event id

13. Tradeoffs & Alternatives

ChoiceOption AOption BUsual pick
Where keys liveSame DB as the data: one transaction, true atomicitySeparate Redis: faster, but two systems can disagreeSame DB for money; Redis for cheap ops
What to storeFull response: exact replayStatus + object id: 10× smallerStatus + id, rebuild the response
Duplicate while runningReturn 409 nowBlock until the first finishes409; blocking ties up threads
Who makes the keyClient: covers the whole pathServer-derived from content: no client changeClient key; content-derived as a backup
Publishing eventsOutbox with polling relayCDC from the logCDC at scale; polling is simpler to start

Worked example: a successful charge with a lost response

The caller uses key p42; the provider charges, but its reply is lost.

Worked example, in orderClaim p42: Persist scoped uniqueness, request fingerprint, and state. Concurrent same-payload retries see in-progress; payload mismatch is rejected.. Call the provider with p42: Forward its supported stable identity. A local unique claim cannot make a remote call transactional.. Preserve uncertainty: Do not delete p42 on timeout/5xx. Query or retry the same provider operation before deciding the effect failed.. Store final outcome: Couple local business state and final response in one transaction where possible. Financial natural IDs can outlive response-cache TTLs.1Claim p422Call the provider with p423Preserve uncertainty4Store final outcome
Read top to bottom. The table below explains the state and guarantee at each step.
StepState, calculation, and reasoning
1. Claim p42Persist scoped uniqueness, request fingerprint, and state. Concurrent same-payload retries see in-progress; payload mismatch is rejected.
2. Call the provider with p42Forward its supported stable identity. A local unique claim cannot make a remote call transactional.
3. Preserve uncertaintyDo not delete p42 on timeout/5xx. Query or retry the same provider operation before deciding the effect failed.
4. Store final outcomeCouple local business state and final response in one transaction where possible. Financial natural IDs can outlive response-cache TTLs.
Check your understanding: When is it safe to retry as a new operation?

Only after proving the original effect did not and cannot happen, or when a downstream/natural-key contract deduplicates it safely. Timeout alone is not proof.

Run a small, testable model

Download concepts.py. It uses only the Python 3.10+ standard library. From the directory where you saved it, run:

python3 concepts.py dedup
python3 concepts.py test

The SQLite example covers transactional local dedup and payload mismatch; the workflow model represents remote uncertainty.

The program contains deterministic assertions and failure traces. It is a learning model, not a production service: it does not implement network consensus, distributed durability, authentication, or third-party delivery. Read its contracts and try changing an input before extending it.

Primary references for these concepts

14. A 45-Minute Interview Walkthrough

MinutesWhat to do
0–5Clarify which ops have side effects, who retries, and how late a retry can come.
5–10Explain the three outcomes of a call and why retries are forced. Estimate the key store.
10–20API with Idempotency-Key, the key table, the check-then-run flow.
20–30Break it with two concurrent duplicates. Fix with claim-first and a unique constraint. Add the fingerprint.
30–38Crash mid-work, downstream timeouts, the outbox for events, delivery vs effect.
38–45Naturally idempotent designs and the tradeoffs table.
Interview tip

If you say "exactly-once," add "effect, not delivery" in the same breath. Interviewers listen for it. Then name the recipe: at-least-once delivery plus dedup on a key.

15. Common Mistakes

16. Summary

Idempotency is the discipline that lets a system retry freely without doubling its effects. The pieces reinforce one another:

ConcernMechanism
Why must we retry at all?A lost response is indistinguishable from a lost request, so retrying is the only safe move.
Why do retries hurt?Writes with side effects (charges, orders) get applied twice.
How do we identify a logical request?A client-generated idempotency key, reused across all its retries.
How does the server dedup?A dedup store mapping key → status, response, TTL.
How do we survive concurrent retries?Claim the key first via a unique constraint; losers replay the result.
What can we actually guarantee?Not exactly-once delivery, but exactly-once effect via at-least-once + dedup.
How do we publish events reliably?The transactional outbox: event and data commit together; a relay publishes once.
How do we avoid the problem entirely?Naturally idempotent operations: upserts, conditional writes, deterministic ids.
The through-line: you cannot stop duplicates from being delivered, so you make them harmless. Keys give every operation an identity, the dedup store remembers what was done, a unique constraint tames the concurrent case, and naturally idempotent writes remove the problem where you can.

17. Check Yourself

  1. A client times out on POST /payments. Why can it not tell whether it was charged?
    Answer

    A call has three outcomes: success, failure or no answer. A lost response looks the same as a lost request, so the client cannot know if the server ran the charge.

  2. Two copies of one request arrive 1 ms apart. Both do get(key), find nothing and run. What single change fixes it?
    Answer

    Claim the key before the work with an insert guarded by a unique constraint. Only one insert can win. The loser gets a violation and returns 409 or the stored result.

  3. A retry comes with the same key but amount changed from 50 to 500. What should the server do?
    Answer

    Return 422. The stored fingerprint does not match the new body, so this is a different request reusing a key, which is a client bug. Replaying the old answer would hide it.

  4. The first attempt got a 503 from the processor. Should the server store that response against the key?
    Answer

    No. A 503 is a transient failure. If it were stored, every retry would replay the 503 and the payment would never go through. Delete or release the key so a retry can run.

  5. At 5K requests/s with a 24-hour TTL, how many keys are stored, and how big is the store at 200 B per key?
    Answer

    5,000 × 86,400 = 432M keys. 432M × 200 B = 86.4 GB.

  6. Why is the outbox relay at-least-once and not exactly-once?
    Answer

    The relay can publish an event and crash before it marks the row sent. On restart it publishes again. Consumers make the result exactly-once in effect by deduping on the event id.

18. Key Terms

Idempotent
A client-generated identity for one logical operation. The server binds it to caller scope, endpoint, request fingerprint, state, and result. A retry uses the same key and reuses the original outcome rather than repeating the effect. Example and limits above.
Idempotency key
A client-generated identity for one logical operation. The server binds it to caller scope, endpoint, request fingerprint, state, and result. A retry uses the same key and reuses the original outcome rather than repeating the effect. Example and limits above.
Dedup store
Table mapping each key to its status and result.
Request fingerprint
A hash of the body, stored to catch key reuse.
At-least-once delivery
Retry until acked; duplicates are possible.
Exactly-once effect
At-least-once delivery plus dedup on the receiver.
Transactional outbox
A business transaction writes its state change and an event row to the same database. A relay publishes committed event rows later, retrying until the broker durably accepts them. Example and limits above.
CDC
A business transaction writes its state change and an event row to the same database. A relay publishes committed event rows later, retrying until the broker durably accepts them. Example and limits above.
Conditional write
Update only if a condition, such as a version, still holds.
Two generals problem
Proof that two parties cannot be sure of agreement over a lossy link.

19. Further Reading


⌂