Why the Same Request Twice Must Not Charge Twice

I remember sitting in a cold data center at 3:00 AM, watching a dashboard bleed red as a single retry loop from a faulty mobile client began systematically draining a client’s account balance. Most tutorials treat idempotency in api design like a theoretical checkbox—a neat little concept you implement during a “design phase” and then forget about. But when you’re maintaining legacy payment gateways, you learn quickly that it isn’t a theory; it’s the only thing standing between a stable system and a total financial catastrophe.

I’m not here to give you a sanitized, high-level overview of RESTful principles or show you a “happy path” diagram that looks good in a slide deck. Instead, I want to talk about the messy reality of distributed systems: the race conditions, the partial failures, and the edge cases that actually keep you awake at night. I’m going to show you how to build safeguards that actually work when the network gets flaky, ensuring that a single accidental double-tap doesn’t turn your database into a crime scene.

Table of Contents

The Double Tap Disaster Handling Duplicate Requests Under Pressure

The Double Tap Disaster Handling Duplicate Requests Under Pressure.

I’ve spent enough time in payment gateways to know that “edge cases” are usually just the reality you haven’t met yet. Imagine a client sends a POST request to charge a customer, the database updates, but the network hiccups before the 200 OK reaches the sender. The client, being a well-behaved piece of software, retries. Without a solid idempotency key implementation, your backend sees a brand-new request and executes it again. Suddenly, you aren’t just processing a transaction; you’re preventing double charging in payments by sheer luck rather than design.

In a distributed environment, this isn’t just a minor glitch; it’s a systemic failure. When you’re dealing with microservices, a retry loop can trigger a cascade of duplicate writes across your entire stack. You can’t just rely on database constraints to save you—by the time a unique constraint violation hits, the side effects might have already leaked into your message queues or downstream services. You need to treat every incoming request as a potential duplicate from the moment it hits your middleware, ensuring that handling duplicate requests is a core architectural requirement, not an afterthought.

Distributed Systems Fault Tolerance When Everything Goes Wrong

Distributed Systems Fault Tolerance When Everything Goes Wrong

When you move from a single monolith to a cluster of microservices, the “happy path” disappears. In a distributed environment, network partitions and timeouts are not bugs; they are certainties. You might send a request to a payment gateway, the gateway processes it successfully, but the connection drops before you get the `200 OK`. Your service thinks it failed, so it retries. Without a robust idempotency key implementation, you aren’t just managing state; you’re playing Russian roulette with your ledger.

This is where true distributed systems fault tolerance is tested. It isn’t enough to just wrap your logic in a try-catch block. You need to ensure that your database updates and your external API calls are treated as a single unit of work, or at least coordinated so that a retry doesn’t trigger a second transaction. If your service recovers from a crash and starts re-processing a queue of “failed” jobs, you better be certain those jobs can be safely re-executed. If you haven’t designed for the partial failure state, you haven’t actually built a distributed system; you’ve just built a way to create data corruption at scale.

Five Ways to Stop Your Database from Becoming a Crime Scene

  • Stop relying on client-side timestamps. If you trust the client to tell you when a request happened, you’ve already lost. Use a unique idempotency key generated by the client and stored in your database to track what has actually been processed.
  • Design your database constraints to do the heavy lifting. A unique constraint on your idempotency key is your last line of defense. It’s better to let a database transaction fail and throw an error than to let two identical rows slip through because your application logic had a race condition.
  • Don’t just return a 200 OK for a duplicate request. If the original request succeeded, return the original response, but make sure your middleware knows the difference between a fresh execution and a replay. Your client needs to know the state hasn’t changed.
  • Keep your idempotency window reasonable. You can’t store every key forever without bloating your storage, but you can’t purge them too fast either. Find the middle ground—usually 24 to 48 hours—based on how long your clients typically retry after a timeout.
  • Clean up your side effects before you commit. If your process involves hitting a third-party payment gateway and then updating your local DB, ensure the gateway call is wrapped in a way that a local rollback doesn’t leave a ghost transaction living in the external system.

The Hard Lessons

Stop treating idempotency as an “extra feature.” If you’re handling money or state changes, it is a core requirement; without it, your system is just a ticking time bomb of duplicate entries.

Your error handling is where the real work happens. A successful implementation isn’t just about returning a 200 OK; it’s about how gracefully you handle that second, identical request without throwing a 500 or, worse, creating a second record.

Don’t trust the network. Assume every client is flaky and every connection will drop halfway through a handshake. Build your logic around the assumption that the same request will arrive multiple times.

The Cost of a Single Retry

Most tutorials treat idempotency as a theoretical design pattern, but in production, it’s the only thing standing between a momentary network hiccup and a database full of duplicate transactions that you’ll be spending your entire weekend trying to reconcile.

Pernille Askeland

The Cost of Doing Nothing

The Cost of Doing Nothing in systems.

At the end of the day, idempotency isn’t some academic concept to be tucked away in a design document; it is your primary defense against the chaos of a distributed system. We’ve seen how a single network hiccup or a client’s aggressive retry logic can turn a clean transaction into a database nightmare if you aren’t prepared. Whether you are implementing idempotency keys, leveraging unique constraints, or managing state transitions carefully, the goal remains the same: ensuring that a retry is a non-event rather than a catastrophe. You can’t prevent the network from failing, but you can absolutely prevent those failures from corrupting your source of truth.

I spent the first decade of my career fixing the mess left behind by developers who assumed the “happy path” was the only path. It’s a hard way to learn, and I don’t recommend it. When you sit down to write your next service, don’t just code for the success response; code for the glitch, the timeout, and the duplicate. Building resilient systems is less about the elegant logic of the feature and more about the quiet reliability of the edge cases. That is where real engineering happens.

Frequently Asked Questions

How do I handle idempotency keys when the client loses connection before receiving the initial response?

This is where the “happy path” tutorials fail you. The client sends the key, your server processes the payment, but the network drops before the `200 OK` hits the wire. To the client, it’s a timeout; to your database, it’s a completed transaction. You must treat that incoming retry as a lookup, not a new command. If the key exists, return the original cached response. Don’t re-run the logic; just tell them it’s already done.

Is it better to store idempotency tokens in a fast cache like Redis or should they live in my primary relational database?

I’ve seen both approaches fail, but for payments, I lean toward the relational database. Redis is tempting for its speed, but if that cache evicts your token due to memory pressure right before a retry, you’re back to square one: a double charge. If your idempotency token lives in the same ACID transaction as your ledger update, you have a single source of truth. Don’t trade consistency for a few milliseconds of latency.

At what point does the overhead of checking for duplicate requests start to degrade my system's latency too much?

It’s a trade-off, not a hard wall. You’ll feel the friction when your idempotency check involves a heavy cross-region database lookup for every single write. If you’re hitting a centralized SQL instance for every request, your latency will spike as you scale. I usually lean on a fast, TTL-based Redis layer for the initial check. If the overhead starts eating your p99s, your locking strategy is likely too coarse.

About Pernille Askeland

I write the part the tutorial skipped: what it looks like when it breaks.

Scroll to Top