Asyncidempotencyidempotency keyretryat-least-onceexactly-once

Idempotency

A network gives you at-least-once delivery whether you like it or not; idempotency — the same request applied twice has the effect of once — is what turns "at least once" into the behaviour users mean by "exactly once".

▶ InteractiveInterview questionDebug it
Progress
What problem does this solve?

A client sends POST /payments, the gateway times out after 30 s, the client retries — but the first request had reached the payment service and charged the card. Idempotency makes the retry safe: the second request finds the first result and returns it instead of charging again.

Did we charge twice?

A client cannot distinguish "my request was lost" from "my request succeeded and the response was lost". From the outside both look like a timeout. The only safe client behaviour is to retry, and the only safe server behaviour is to make retries harmless. The order service that charges a card in 200 ms usually, then once hits a 30 s gateway timeout because the payment provider was slow — and charges again when the client retries — is not a rare bug; it is the default behaviour of every non-idempotent write on a network.

The same problem appears wherever there is a retry: a message queue redelivers after a consumer crash (Message Queues), a worker re-runs a job after a lease expires (Background Jobs and Workers), a saga step is re-executed after an orchestrator restart (Saga Pattern), an agent retries a tool call after a timeout (Tool Errors, Retries and Timeouts). "Exactly once" is not a delivery guarantee any network can give; it is a property you construct on the receiving side, from at-least-once delivery plus an idempotent receiver.

The retry that reaches a service twice
POST ×2 (retry)same keyseen? → cached responsecharge onceClientGateway (30 s timeout)Payment serviceIdempotency storeCard provider
ClientGateway / LBServiceWorkerDatabaseCacheQueue / LogObject storageCDNExternal system

Idempotency keys

The mechanism used by every payment API: the client generates a key (a UUID) for each logical operation and sends it as Idempotency-Key. The server stores the key with the response it produced. A second request with the same key does not re-execute anything — it returns the stored response, status code and all. The key must be chosen by the client because only the client knows that two HTTP requests are the same *intent*; a server-generated id would be different for each retry.

Details that matter in production. Keys are scoped: per user or per API credential, so two customers who both happen to send key 1 do not collide, and usually per operation type. Keys have a TTL — 24 h is common — because storing every response forever is a database of its own. The store must reject a *different* payload under the same key (422), otherwise a bug that reuses keys silently returns the wrong customer's response. And while the first request is still in flight, a duplicate must wait or return 409, not start a second execution: the check-then-insert must be atomic. Redis SET key value NX EX 86400 does exactly that in one command; a unique index on (user_id, key) does it in SQL, and the insert failing is the signal that this is a retry.

An idempotent handler: claim the key atomically, execute once, store the response
1async function charge(req: Request, res: Response) {
2 const key = req.header('Idempotency-Key')
3 if (!key) return res.status(400).json({ error: 'Idempotency-Key required' })
4 const scope = req.user.id + ':charge:' + key
5 const hash = sha256(JSON.stringify(req.body))
6
7 // Atomic claim: NX means "only if absent". A retry sees the existing record.
8 const claimed = await redis.set(scope, JSON.stringify({ hash, status: 'pending' }), { NX: true, EX: 86400 })
9 if (!claimed) {
10 const prior = JSON.parse((await redis.get(scope)) ?? '{}')
11 if (prior.hash !== hash) return res.status(422).json({ error: 'key reused with a different payload' })
12 if (prior.status === 'pending') return res.status(409).json({ error: 'original request still in progress' })
13 return res.status(prior.code).json(prior.body) // replay the stored response
14 }
15
16 const result = await provider.charge({ ...req.body, idempotencyKey: scope }) // provider dedups too
17 const record = { hash, status: 'done', code: 201, body: { paymentId: result.id } }
18 await redis.set(scope, JSON.stringify(record), { EX: 86400 })
19 return res.status(201).json(record.body)
20}

Natural idempotency and the exactly-once illusion

Some operations are idempotent by shape and need no key. PUT /users/42 { name: "Ada" } sets a value; doing it twice sets the same value. DELETE /orders/7 twice leaves the order equally deleted (return 204 both times, not 404 on the second). An UPSERT ... ON CONFLICT DO UPDATE keyed on a natural id is idempotent; INSERT is not. SET balance = 80 is idempotent; UPDATE balance = balance - 20 is not — and the difference between those two statements is most of the difference between a system that survives retries and one that does not. Designing the API so writes are *replacements keyed by identity* rather than *increments* removes the need for a key store for most endpoints; keep keys for the operations that are inherently one-shot (charge, send, create).

Systems that advertise "exactly-once" — Kafka transactions, stream processors — implement it as at-least-once delivery plus deduplication by sequence number inside a bounded scope, and the guarantee stops at the boundary of that scope. The moment the effect leaves the system (an email, a card charge, an HTTP call to someone else) it is at-least-once again, and the receiver needs its own idempotency. That is why the honest design statement is: deliver at least once, make every receiver idempotent. The interview question exactly-once is testing whether you know this.

Idempotent consumers

A queue consumer or event handler gets the same message twice whenever it crashes between doing the work and acknowledging. The standard defence is a processed-message table: inside the *same* database transaction as the business write, insert (consumer_name, message_id) into a table with a unique constraint. If the insert conflicts, the message was already handled — roll back and acknowledge. Because the check and the effect commit atomically, a crash at any point leaves either both or neither. This only works when the effect is in the same database; when the effect is an external call, fall back to keys the external system honours.

Ordering interacts with idempotency: an OrderUpdated that arrives twice is harmless if applied as "set status to shipped", harmful if applied as "advance status by one". Design event handlers as upserts of a target state where possible, and carry a version or sequence number so a stale duplicate (v3 arriving after v4) is ignored rather than applied — the same discipline Event-Driven Architecture and Kafka-Style Logs: Topics, Partitions, Offsets rely on. The lock-free version of the processed-message check is a race under the default isolation level; see Isolation Levels for why a unique index, not a SELECT first, is the correct guard.

Where idempotency comes from
SituationMechanismScope of the guarantee
HTTP write that creates or chargesClient key stored with the response, TTL, atomic claimPer user + operation, for the TTL
HTTP write that sets stateMake it a PUT / upsert keyed by identityUnlimited — no store needed
Queue consumer writing to its own DBProcessed-message table in the same transactionPer consumer, for as long as rows are kept
Queue consumer calling an external APIPass a key the provider dedups on (message id)Whatever the provider promises (often 24 h)
Event that advances a stateCarry a version; apply only if newerPer aggregate

Key points

  • A timeout does not tell the client whether the write happened; the only safe protocol is retry + idempotent server.
  • Idempotency key: client-generated, scoped per user and operation, claimed atomically, stored with the full response, expired by TTL.
  • Prefer naturally idempotent writes — PUT, upsert, SET x = v — over increments; keep keys for one-shot operations like charge and send.
  • "Exactly once" is at-least-once delivery plus deduplication at the receiver; the guarantee ends where the effect leaves your system.
  • Consumers dedup with a processed-message table committed in the same transaction as the effect, and ignore stale versions.

Did we charge the customer twice?

Did we charge the customer twice?
POST /payment for €49. Pick where the failure happens and whether the request carries an idempotency key, then step through the retry.
POST /payment €49chargeClientGateway (30 s timeout)Payment serviceProvider
Failure
charges made
0 × €49
customer sees
spinner
step
request
key
idem-7c2f…a1
keystatusresponsettl
idem-7c2f…a1in-progress24 h
The client generated idem-7c2f…a1 for this checkout attempt and sends it as Idempotency-Key. The service inserts an in-progress row for the key first — that insert is what claims the operation.

Networks give you at-least-once: a request that timed out may or may not have happened. "Exactly-once" is an illusion built from at-least-once delivery plus a deduplication key. Scope the key per client operation (one checkout attempt, not one user), store it together with the response so a replay returns the same answer, and expire it (24 h is typical) so the store does not grow forever.

1/5 · request

How data moves through it

One request or event, hop by hop.

  1. 1Client → Gateway: POST /payments with Idempotency-Key: 7f3a…; the gateway forwards and starts its 30 s timer.
  2. 2Service → Key store: SET user:42:charge:7f3a… NX EX 86400; claimed on the first request, refused on the retry.
  3. 3Service → Provider: charge with the same key, so even a duplicate that slipped through is deduplicated once more.
  4. 4Service → Key store: overwrite the record with status, code and body; Service → Client: 201.
  5. 5Client retry → Service: claim refused, record found, stored 201 returned; no second charge.

When to use — and when not

Use it when
  • Every write that a client, gateway, queue or worker might retry — which is every write that crosses a network.
  • Payments, order creation, notifications and any call to a provider that charges money or sends something to a human.
  • Event and queue consumers, which are redelivered by design after any crash before acknowledgement.
Avoid it when
  • There is nothing to skip: reads are idempotent by definition and need no key store.
  • Writes already idempotent by shape (PUT with full state, upsert by identity) — adding a key store on top is machinery without benefit.
  • Operations where the second execution is *wanted* (append a comment, add to cart with quantity) — here design the API so the client sends the intended final state or a unique item id instead.

Tradeoffs

Complexity
low → high
Ops cost
low → high
Latency
low → high
Consistency
weak → strong
Scalability
poor → strong

One extra store lookup per write and a key table to expire; in exchange retries become free and duplicates become impossible rather than unlikely.

How it fails

  • Server-generated keys: each retry gets a fresh key and nothing is deduplicated.
  • Check-then-insert without atomicity: two concurrent retries both see "absent" and both execute — the exact race the key was meant to prevent.
  • Key stored before the response is known, and the process dies: the retry finds pending forever. Give pending records a short TTL or a recovery path.
  • Unscoped keys collide across users; a key reused with a different payload returns someone else's response.
  • Consumer marks the message processed in one transaction and does the work in another — a crash in between loses the work or duplicates it.

How it scales

  • The key store is a hash lookup per write; Redis handles hundreds of thousands per second, and TTLs keep the set bounded.
  • Shard the store by the same key as the service (user id) so the check stays local to the partition that handles the request.
  • Processed-message tables grow with volume; partition by time and drop old partitions once the redelivery window has passed.
  • At very high event rates, per-partition sequence numbers replace per-message tables: keep the last-seen sequence per key, in memory.

How it interacts with databases, queues, caches, APIs and external systems

  • Cache/Redis: the key store — SET NX EX gives the atomic claim and the TTL in one operation (see Redis: Data Structures, Not a Cache).
  • Database: a unique index on (user_id, key) is the SQL equivalent; a processed_messages table protects consumers.
  • Queue: consumers dedup by message id; brokers may also offer a deduplication window (SQS FIFO, 5 min) — useful but not sufficient.
  • External APIs: forward a key the provider honours; most payment and email providers accept one and deduplicate for 24 h.
  • API gateway: can enforce that mutating requests carry a key and reject those that do not, before they reach any service.
Don't delegate understanding
The manifesto →
Use abstractions. Know what they hide.