Debugging & Real-World · Lesson 05
Idempotency in practice
A user double-clicks the "Pay" button. Your server's response is lost in transit and the client retries. Both scenarios send the same request twice — and without idempotency, both cause a double charge. Idempotency keys turn "retry safely" from a hopeful claim into a mechanical guarantee.
By the end you'll be able to
- Explain why "at-most-once" semantics are required for non-idempotent operations and how idempotency keys provide them.
- Implement a dedup store that returns the cached response for a repeated idempotency key.
- Design an idempotent webhook consumer that handles duplicate deliveries safely.
The problem: two sends, two effects
An operation is idempotent if applying it multiple times produces the same result as applying it once. GET, PUT, and DELETE are designed to be idempotent. POST is not — two identical POST requests to /charges are intended to create two charges. That's fine in a world with perfectly reliable networks, but networks are not perfectly reliable.
Consider this sequence:
A user clicks "Pay $49." Your client sends a POST to /v1/charges. The server processes the charge, deducts $49 from the card, and sends a 200 response. The response is lost in transit — a TCP timeout. The client's HTTP library, seeing no response, retries the same POST. The server receives a second POST with an identical body and charges the card again. The user is now charged $98.
The user complains. Your server logs show two successful charges with identical amounts and identical metadata. Neither is "wrong" in isolation — both were valid API calls. The system did exactly what it was told, twice.
The same failure mode appears in other shapes: a "Pay" button with no loading state that a user clicks twice before seeing a response; a background job that retries on any error, not just safe ones; a webhook that fires twice for the same event.
The fix: idempotency keys end-to-end
An idempotency key is a client-generated unique identifier attached to a request. The server uses it as a deduplication key: the first request with a given key is processed and the result is cached; any subsequent request with the same key returns the cached result without re-executing the operation.
The key property: the client generates the key before sending the request, and reuses the same key on retries. This way, even if the response was lost and the request is sent again, the server recognizes it as a repeat and returns the cached response — the charge is not re-executed.
How idempotency keys work over HTTP
The client sends the key in a request header. Stripe uses Idempotency-Key; PayPal uses PayPal-Request-Id; other providers may use X-Idempotency-Key. The value should be a UUID (version 4) generated fresh for each logical operation — not for each retry of the same operation.
Implementing the dedup store
The server-side implementation requires a store that maps idempotency keys to cached responses. The store must persist at least as long as the retry window you want to support (24–48 hours is common). Redis with a TTL works well; a database table also works. The critical operations:
- On receiving a request: look up the idempotency key in the store. If found, return the cached response immediately — do not re-execute the operation.
- If not found: execute the operation (charge the card, create the record), capture the response, write the key + response to the store, then return the response.
- Handle the in-flight race: if two requests with the same key arrive simultaneously, one should execute and the other should wait for the result. Use a database lock or a Redis SET NX (set if not exists) to prevent concurrent processing of the same key.
If the first request results in a card_declined error, should a retry return the same error or try the card again? The answer: return the same error. Retrying an operation that definitively failed is wasteful and potentially harmful (repeatedly attempting a blocked card may trigger fraud detection). Cache both success and error responses. The only responses that should NOT be cached are transient infrastructure errors (500, 503) — those are safe to retry.
Idempotent webhook consumers
Webhook handlers are the consumer side of the same problem. As covered in Lesson 04, the provider delivers at-least-once — your handler may process the same event twice. The idempotency key in this case is the event's unique ID provided by the provider.
If you record the event ID in one step and update the order in another step, a crash between the two steps leaves your system in an inconsistent state — either the event is marked processed but the order wasn't updated, or the order was updated but the event wasn't recorded and will be re-processed. Do both in a single atomic transaction so either both happen or neither does.
Idempotency and the request body must match
What happens if a client sends the same idempotency key with a different request body? For example, key abc123 first with amount: 4900, then with amount: 2500? The server should return an error — the key is associated with a specific operation, and allowing the body to change would make the guarantee meaningless. The standard response is 422 with an error code like idempotency_key_in_use.
Clients can use this as a debugging signal: if they're getting idempotency_key_in_use errors, they're accidentally reusing keys across different operations — a bug in how they generate keys.
Tying it together: end-to-end idempotency
A fully idempotent payment flow looks like this:
- Client generates a UUID before the user clicks "Pay." Store it associated with the user's current checkout session.
- Client sends the charge request with the UUID in the
Idempotency-Keyheader. - Server checks the dedup store before executing the charge. If the key exists, return the cached response.
- If new: charge the card, cache the result with the key (both success and card errors), return the response.
- On network failure: the client retries with the same key. The server returns the cached result. The card is not charged again.
- Payment provider sends a webhook confirming the charge. Your webhook handler deduplicates by event ID in the same database transaction as the order status update.
- Result: regardless of how many times the request was sent (double-click, network retry, webhook retry), the user is charged exactly once.
Idempotency is a common system design topic at companies with payment or transaction systems (Stripe, Airbnb, Lyft, Uber). The question usually starts as "how would you prevent double charges?" Candidates who answer "check for duplicates" without explaining the idempotency key pattern (client-generated UUID, sent as a header, cached on the server) are giving an incomplete answer. The three-part explanation — key generation, dedup store, caching error responses — is the complete answer. Also note that this concept extends to any non-idempotent operation: sending an email, creating a subscription, provisioning infrastructure.
Under the hood: how it actually works
Idempotency keys look simple from the outside — "send a header, get dedup." The mechanism that makes it safe under concurrent duplicates is the interesting part.
The dedupe store: keyed by idempotency key, atomic insert-if-absent
The server maintains a store (Redis or a database table) whose schema is conceptually:
-- Database table equivalent
CREATE TABLE idempotency_keys (
key TEXT PRIMARY KEY, -- the UUID the client sent
status TEXT NOT NULL, -- 'pending' | 'complete'
request_hash TEXT NOT NULL, -- hash of the request body (body-mismatch guard)
response JSONB, -- cached {status_code, body}
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
expires_at TIMESTAMPTZ NOT NULL -- TTL: typically now() + 48 h
);
Atomic insert-if-absent: how SET NX prevents races
The critical invariant is: exactly one thread must execute the underlying operation, even when two requests with the same key arrive simultaneously. A plain "check then execute" is a race — both threads can read "key not found" and both proceed to charge the card. The fix is an atomic insert-if-absent:
- Redis:
SET idem:{key} "pending" NX EX 172800— "set only if Not eXists, with a 48-hour expiry." This is a single atomic command. Only one concurrent caller wins; the other getsnilback. - PostgreSQL:
INSERT INTO idempotency_keys … ON CONFLICT (key) DO NOTHINGfollowed by checking the row count — rows-affected = 0 means another request already claimed the key.
The losing concurrent request sees the key in pending state. The correct behaviour is a short-circuit: return a 409 Conflict (or optionally poll and wait a few seconds for the pending → complete transition). This is safer than blocking indefinitely — the winning request may have crashed, leaving the key stuck at pending without a result.
Storing and replaying the first response
After the operation completes (success or deterministic error), the server overwrites the pending row with the full response — both the status code and the body. Subsequent requests look up the key, find it complete, and replay the stored response verbatim. The client cannot tell whether it received a live response or a cached replay — that is the guarantee. The stored body must include everything the client needs (charge ID, amount, status), not just a "seen" flag.
The TTL trade-off
The TTL controls how long the server remembers a key. Longer TTLs give clients more time to retry after extended outages but consume more storage. The practical formula: TTL should exceed the longest plausible retry window for your clients. For a payment API, 24–48 hours covers mobile apps that go offline and retry next morning. After the TTL, the key is evicted — a retry with the same key would be treated as a fresh request. This is why clients must not reuse keys across sessions: the key should be tied to one logical operation and discarded after success.
Traced duplicate: two simultaneous POSTs, one charge
If thread A crashes after claiming the key but before writing the result, the key is stuck at pending indefinitely with no result. Subsequent retries by the client will always get 409. The fix: set a short TTL on the pending entry (e.g., 30 s), separate from the long TTL on completed entries. After 30 s, a retry from the client will get a cache miss and start fresh — executing the operation with a clean slate. Stripe's implementation effectively uses this approach under the hood.
- Stripe — Idempotent requests (including pending-key behaviour)
- Redis SET NX — atomic insert-if-absent documentation
- AWS Builder's Library — Making retries safe with idempotent APIs
🧠 Quick check
1. When should a client generate an idempotency key?
The client generates the key once before the first attempt and uses the same key on every retry of the same logical operation. Generating a new key on each retry defeats the purpose — the server would see each retry as a fresh request with no cached result. The server never generates the key; it only uses the key the client provides to look up cached results.
2. A charge request returns a card_declined error. The client retries immediately with the same idempotency key. What should the server return?
A card decline is a deterministic result (the issuing bank declined it). Retrying the charge will not change that outcome. The server should cache the error response and return it on retries — this prevents unnecessary charges to the card network and protects against fraud detection triggers from repeated failed attempts. Only transient infrastructure errors (500, 503) warrant a retry.
3. Why should you record a webhook event ID in the same database transaction as the business operation it triggers?
If you record the event ID first and the business operation fails, the event appears processed but the actual work wasn't done. If you do the business operation first and crash before recording the event ID, the event will be re-delivered and re-processed. Recording both atomically in one transaction means either both commit or neither does — the operation is either fully done or fully not done, with no inconsistent in-between state.
4. A client sends a request with idempotency key key-xyz and amount $49. Later, they send another request with the same key but amount $25. What should the server do?
An idempotency key is bound to a specific operation — it cannot be repurposed for a different request body. Silently returning the $49 result for a $25 request would confuse the client; executing a new charge would violate idempotency. The server returns a 422 telling the client that this key is already in use and a new key must be generated for the new operation.
✍️ Exercise: trace a double-charge failure and fix it
A production system is creating duplicate subscriptions. Investigation reveals that the subscription-creation endpoint does not accept or check idempotency keys. Occasional network timeouts cause the client's HTTP library to retry the POST, and both the original and the retry create separate subscriptions.
Write a plan (5 steps) for adding end-to-end idempotency to this flow. Consider: key generation, server-side dedup, transaction atomicity, and what to do about existing duplicates.
Model answer:
- Client: generate a key per subscription attempt. Before calling the API, generate a UUID and store it with the user's session. This key is reused for all retries of this specific subscription creation. The key is discarded once the operation either succeeds or deterministically fails (not on transient errors).
- Server: add an
Idempotency-Keyheader check. Make the header required forPOST /subscriptions. Return 422 if the header is absent. This is a breaking change for existing clients — add it in a versioned endpoint (/v2/subscriptions) or via a migration with a deprecation notice for existing callers. - Server: implement the dedup store. Use a Redis key
idem:sub:{key}with a 48-hour TTL. On first receipt: claim the key with SET NX, create the subscription, cache the result. On subsequent receipt: return the cached response without re-executing. - Server: wrap key recording and subscription creation in one transaction. If the subscription creation fails due to a database error, the key claim should be released (or left as PENDING with an expiry so the client can retry). On success, update the Redis entry from PENDING to the full cached response atomically.
- Existing duplicates: reconcile manually. Query for subscriptions created within 30 seconds of each other for the same customer and product. Merge or cancel duplicates, issue refunds where applicable, and add a monitoring query to alert on future duplicates before a fix is deployed.
Rubric: ✓ Client generates key before request, reuses on retry ✓ Server checks key before executing ✓ Dedup store with expiry ✓ Atomic transaction covers both key write and business operation ✓ Plan addresses existing duplicates (not just future ones).
Key takeaways
- A double charge (or duplicate send) happens when a non-idempotent POST is retried without protection. It's not a bug in the user's behavior — it's a gap in your system's design.
- The fix is an idempotency key: a client-generated UUID sent in a header, with the server caching the result and returning it on duplicate requests.
- Generate the key before the first attempt. Reuse the same key on retries. A new key is for a new logical operation.
- Cache error responses too — only retry on transient infrastructure errors (5xx), not on deterministic failures (card declines, validation errors).
- Webhook handlers implement the same pattern: deduplicate by event ID, and record the event ID in the same database transaction as the business operation.
- Reject requests that send an existing key with a different body — the key is bound to a specific operation.