Idempotency: The Technique That Separates Amateur Systems from Production-Grade Ones
Understand why idempotency is essential for distributed systems and learn how to implement idempotent operations with practical examples in Node.js and SQL.
Thiago Saraiva

Picture this scenario: your user clicks "Pay" and the request times out. They click again. And again. The next morning, you wake up to 3 charges on their card and a furious email in your inbox. Congratulations, you just discovered in the worst possible way why idempotency matters.
What Is Idempotency?
An operation is idempotent when executing it multiple times produces the same result as executing it once. In mathematical terms: f(f(x)) = f(x).
Everyday examples:
abs(abs(-5)) = abs(-5) = 5-- idempotentx + 1-- NOT idempotent:(x+1)+1 != x+1
In distributed systems, this is critical because network failures, timeouts, and retries are inevitable. If your operations aren't idempotent, each retry can cause unintended side effects.
Mental Model: The Elevator Button
Pressing the elevator call button 5 times still summons exactly one elevator. That's idempotency. Contrast that with a light switch: each toggle flips the state, so
toggle(toggle(off)) = off, buttoggle(off) = on. Not idempotent. However, "set light to ON" IS idempotent, no matter how many times you shout it, the bulb stays on. Moral of the story: design operations as declarations of desired state, not as toggles.
HTTP Methods and Idempotency
Before anything else, it's worth remembering what the HTTP spec already defines:
- GET: idempotent (reads don't modify state)
- PUT: idempotent (overwrites with the same value)
- DELETE: idempotent (deleting an already deleted resource = same state)
- POST: NOT idempotent (each call creates a new resource)
- PATCH: generally NOT idempotent (depends on the implementation)
The problem lies with POST. And guess which method most APIs use to create orders and process payments?
War Story: When Idempotency Saves Careers
In 2021, Robinhood users reported being double-charged on trades during periods of high volatility. The root cause traced back to clients retrying requests after timeouts while the original request was still mid-flight on the backend. Classic "timeout is not a failure" trap.
Stripe saw this coming years earlier and wrote the canonical spec for idempotency keys: a client-generated key, stored server-side, that short-circuits duplicate work. The spec is so battle-tested it's now an IETF draft. If you've ever received the same Shopify webhook twice in 4 seconds (you have), you understand why every serious payment processor treats idempotency as non-negotiable infrastructure, not a feature.
Strategy 1: Idempotency Keys (Stripe Pattern)
Stripe popularized this pattern and it's brilliant in its simplicity. The client generates a unique UUID and sends it in the header of each request:
On the frontend, the retry uses the same key:
How Long Should Idempotency Keys Live?
Short answer: it depends on your retry window. Long answer: it's a trade-off between storage cost and the "correctness window" (how long you can safely guarantee no duplicates).
- Stripe: 24 hours. Reasonable for payment flows where clients rarely retry after a day.
- AWS SQS FIFO: 5 minutes dedup window. Optimized for message bursts, not human retries.
- Some internal systems: 7 days or more, useful when you have slow batch jobs that might retry overnight.
Rule of thumb: pick the 99th percentile of your retry latency, then double it. Storing keys forever sounds safe but quickly explodes your Redis bill and buys you nothing past the window where retries actually occur.
Strategy 2: UPSERT in the Database
For insertions, UPSERT is your best friend:
The second attempt with the same key simply does nothing. No error, no duplicate.
Strategy 3: Optimistic Locking
For updates, use a version column:
If the version doesn't match, it means another process modified the record first. You detect the conflict instead of creating inconsistency.
Strategy 4: Deduplication Table
For event processing and webhooks:
Idempotency vs Deduplication
People use these interchangeably. They shouldn't. The difference is subtle but important:
- Idempotency is a property of the operation itself.
SET balance = 100is idempotent by design, run it a million times, same result. - Deduplication is a protection layer on top of a non-idempotent operation.
balance = balance - 10is not idempotent, so you bolt on a dedup table keyed by request ID to fake idempotency from the outside.
Think of it this way: idempotency is making your front door lockable. Deduplication is posting a bouncer who remembers faces. Both keep duplicates out, but one is architectural and the other is operational. Prefer designing idempotent operations when possible, fall back to dedup when you can't.
Where Should You Implement Idempotency?
My rule of thumb:
- Financial operations: MANDATORY. Idempotency key + deduplication table.
- Creating resources via POST: Yes. Client-generated UUID or idempotency key.
- Processing queue messages: Yes. Message ID deduplication.
- Receiving webhooks: Yes. Event ID deduplication.
- GETs and DELETEs: Already naturally idempotent.
- PUT with absolute values: Already naturally idempotent.
On the Frontend: Prevent the Double-Submit
Don't rely solely on the backend. In React:
FAQ
1. The client loses the idempotency key mid-retry. Now what?
They generate a new one and you get a duplicate. That's the cost of client-generated keys. Mitigation: persist the key in localStorage or IndexedDB before the first attempt, clear it only after confirmed success.
2. Can I use idempotency with PATCH partial updates?
Yes, but carefully. PATCH with absolute values ({"status": "shipped"}) is idempotent. PATCH with deltas ({"quantity": "+1"}) is not. Combine non-idempotent PATCH with an idempotency key + cached response.
3. What about cache invalidation racing the idempotency check? Classic race: request A writes to DB, cache TTL expires, request B (same key) doesn't find the cache entry and re-executes. Fix: make the DB the source of truth for idempotency (UPSERT on a unique key column), use the cache only as a read-through optimization.
4. Should I use distributed locks instead? Only if you must serialize work globally. Locks are heavier (Redlock, ZooKeeper, etcd) and fail in fun ways (lock holder dies, clock skew, split brain). Idempotency keys are cheaper and fail gracefully. Reach for locks when you genuinely need mutual exclusion, not just dedup.
5. How does this work with GraphQL mutations?
GraphQL doesn't define idempotency semantics, so you're on your own. Add an idempotencyKey argument to your mutation input, or pass it as an HTTP header and read it in your resolver context. Same pattern, different transport.
Key Takeaways
Idempotency is not a nice-to-have. In any system that deals with money, orders, or important side effects, it's mandatory.
Start with the most critical operations (payments, order creation) and expand from there. The combination of idempotency keys on the backend + loading states on the frontend + deduplication tables in the database covers 95% of scenarios.
And if someone tells you "our system will never have retries", answer them: in distributed systems, the question is not IF it will happen, it's WHEN.