Why Idempotency Is Easy to Get Wrong: A Practical Guide
Most engineers treat idempotency like a checkbox: add an Idempotency-Key header, stash the response in Redis, and call it a day. If you’re building a simple CRUD app, that might survive your demo. But in production, idempotency is easy until the second request is different. When that second request arrives with the same key but a different payload, your "simple" cache becomes a liability that hides bugs or, worse, corrupts your state.
Here’s the reality: idempotency isn't just about preventing duplicate writes. It’s about managing the lifecycle of an effect. If your system only handles identical retries, you haven't built an idempotency layer; you’ve built a replay cache.
The Trap of the Second Request
The real trouble starts when the second request isn't a clean replay. What happens if the first request is still running? What if the process crashed after calling a payment provider but before updating your database? Or, most dangerously, what if the client sends the same key with a different amount?
If you blindly replay the first response, you’re lying to the client. If you execute the second request, you’ve violated the contract of the key. My stance is firm: a scoped key reused with a different canonical command must be a hard error. If a client thinks they are retrying a 10 EUR payment but accidentally sends 100 EUR, your server should scream, not silently process the wrong amount.
Building a Durable Idempotency Record
To handle these edge cases, your idempotency layer needs to answer three questions: Who owns this key? What did the first command mean? What outcome can be replayed?
You need a persistent store—PostgreSQL is usually the right tool here—that tracks more than just the response. You need a request_hash. Without hashing the canonical command, you cannot distinguish between a legitimate retry and a client-side logic error.
Here is how you should handle the state machine for your requests:
- None (New Key): Insert an
IN_PROGRESSrecord and execute the operation. - COMPLETED (Same Command): Replay the stored response.
- COMPLETED (Different Command): Return a hard error (400 or 409).
- IN_PROGRESS (Concurrent): Return a 409 Conflict or a 202 Accepted, depending on your latency requirements.
- FAILED: Allow a retry if the error is transient, or return the original failure.
Why Most Implementations Fail
Most developers forget that idempotency is about the effect, not just the HTTP method. A POST request can be made idempotent, but only if you enforce the relationship between the key and the command. If you don't store the request_hash, you lose the ability to validate that the second request is actually a retry.
Don't fall for the trap of storing full response bodies if you can avoid it. While storing the full JSON is convenient, it often leads to leaking PII or stale data. Reconstructing the response from a resource reference is cleaner, though it requires more discipline in your API design.
Ultimately, your idempotency layer is part of your concurrency control. If you aren't handling the "same key, different command" scenario, you aren't actually protecting your system. You're just delaying the inevitable data inconsistency. Read our guide on distributed transaction patterns to see how this fits into a larger architecture.
If you want to build robust systems, stop treating idempotency as a simple cache. Start treating it as a contract between the client and your database. Try this approach in your next sprint and see how many "impossible" bugs disappear. Pass this to a teammate who thinks idempotency is just a header.