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".
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.
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.
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:' + key5 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 response14 }15 16 const result = await provider.charge({ ...req.body, idempotencyKey: scope }) // provider dedups too17 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.
| Situation | Mechanism | Scope of the guarantee |
|---|---|---|
| HTTP write that creates or charges | Client key stored with the response, TTL, atomic claim | Per user + operation, for the TTL |
| HTTP write that sets state | Make it a PUT / upsert keyed by identity | Unlimited — no store needed |
| Queue consumer writing to its own DB | Processed-message table in the same transaction | Per consumer, for as long as rows are kept |
| Queue consumer calling an external API | Pass a key the provider dedups on (message id) | Whatever the provider promises (often 24 h) |
| Event that advances a state | Carry a version; apply only if newer | Per 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?
| key | status | response | ttl |
|---|---|---|---|
| idem-7c2f…a1 | in-progress | — | 24 h |
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.
How data moves through it
One request or event, hop by hop.
- 1Client → Gateway:
POST /paymentswithIdempotency-Key: 7f3a…; the gateway forwards and starts its 30 s timer. - 2Service → Key store:
SET user:42:charge:7f3a… NX EX 86400; claimed on the first request, refused on the retry. - 3Service → Provider: charge with the same key, so even a duplicate that slipped through is deduplicated once more.
- 4Service → Key store: overwrite the record with status, code and body; Service → Client:
201. - 5Client retry → Service: claim refused, record found, stored
201returned; no second charge.
When to use — and when not
- 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.
- 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
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
pendingforever. 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 EXgives 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; aprocessed_messagestable 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.