You know that sinking feeling when a user clicks "Pay" twice because the spinner froze—and your backend quietly double-charges them? That isn't "network unreliability." That's "we didn't design for idempotency."
In distributed systems, retries are not an edge case; they're the default path. Clients will retry. Proxies will retry. Your own code will retry. The only question is whether your system can handle it without corrupting state.
Situation: The "Phantom Double Charge"
You ship a payments API. A customer hits /payments with a POST, the network hiccups, your client never sees the 201, so it retries.
From your user's perspective:
- "I clicked once. Your app broke."
From your database's perspective:
- "I just created two payments, both valid, good luck explaining that."
The bug isn't in the retry. The bug is that the operation wasn't idempotent.
Task: Make Retries Boring
The real job is not "process this payment." The real job is "process this payment in a way that stays correct even if the same request hits us multiple times."
Idempotency means:
- Multiple identical requests have the same effect as a single request.
- After the first success, every duplicate is effectively a no-op in terms of system state.
Boring? Yes. That's the goal.
Action: Treat Requests Like Events, Not Wishes
Here's the simple move most teams skip: give each potentially destructive request a stable identity.
Typical Pattern:
-
Client generates an
Idempotency-Key(UUID, payment intent ID, etc.) - Sends it with the POST:
POST /payments HTTP/1.1
Idempotency-Key: 123e4567-e89b-12d3-a456-426614174000
Content-Type: application/json
{"amount": 5000, "currency": "USD"}
-
Server logic:
- Check if this key has been seen before
- If not: process the request, store result keyed by
Idempotency-Key - If yes: return the previously stored response instead of processing again
Why This Works:
- Network retries become safe—the second call is just a cache lookup
- Client logic simplifies to: "retry until success or clear failure"
- No more worrying about double side effects
- Even if your database is eventually consistent, idempotency keys make this deterministic
Quick Implementation Pattern:
# Pseudocode for the idea
def process_payment(request_body, idempotency_key):
# Check cache first
cached_response = idempotency_cache.get(idempotency_key)
if cached_response:
return cached_response # Already processed
# Process only if new
result = create_payment(request_body)
# Store for future retries
idempotency_cache.set(idempotency_key, result)
return result
A note on HTTP semantics:
- GET, PUT, DELETE are supposed to be idempotent by design
- POST is not, which is why idempotency keys usually show up there
- Some teams use idempotency keys for all mutable operations—even better
Result: Fewer Fires, More Sleep
Once you design around idempotency:
- Payment "double charges" turn into "same payment, same receipt, every time."
- Support tickets about "I only clicked once" quietly vanish
- Retries become a reliability feature, not a reliability bug
- Your team debugs 3 AM incidents with more confidence
Idempotency won't make your API exciting. It will make it trustworthy. And trust is what lets you ship faster without being afraid of your own error logs.
The Deeper Breakdown
If this resonates—if you're into the kind of "real-world backend hygiene"—idempotency, retries, failure modes, the unglamorous stuff that actually keeps systems up—then you might want to check out CoreCraft.
It's a newsletter focused on exactly this: concrete patterns, real trade-offs, code you can use tomorrow. No fluff, no vague "best practices," just battle-tested abstractions for working engineers.
Have a production idempotency horror story? Drop it in the comments. Let's normalize talking about this stuff.
Top comments (0)