Emmanuel EbriEmmanuel Ebri
Back to Blog
Distributed SystemsIdempotencyPayments

Idempotency Is Not a Payments Trick

Anything that can retry needs it, and almost everything retries

5 min read

Idempotency Is Not a Payments Trick cover

Most developers meet idempotency in a payments integration guide, implement it once because the provider insists, and file it under fintech. That filing is the mistake, and it is why the same bug keeps appearing in systems that have nothing to do with money.

The principle is simple enough to state in one line. If an operation can be triggered more than once, it must produce the same result every time, not the same result plus a side effect.

The reason it matters far outside payments is that almost everything in a modern system can be triggered more than once, usually for reasons outside your control.

Count the triggers

Take any handler in your application and list the ways it can fire twice.

The user taps twice, because the first tap gave no feedback on a slow connection. The browser retries a request that the network dropped after the server had already processed it. Your queue redelivers a job, because at least once delivery is what most queues offer and exactly once mostly is not real. A provider webhook arrives, and then arrives again, because the provider did not receive your acknowledgement in time. A cron job overlaps with the previous run that has not finished. A deploy restarts a worker mid task. Someone refreshes a page that submits on load.

None of those are exotic. Most systems have five or six of them active right now, and the only thing standing between them and duplicate records is timing.

The failure looks the same across all domains. Two enrolments for one student. Two invitation emails. Two ledger entries. Two projects created from one purchase. The domain changes, the shape does not.

Fast is not the same as safe

The response I hear most often is that the window is tiny, so the race is unlikely.

Unlikely is a probability statement, and probability statements need a denominator. At ten requests a day, a one in a thousand race is a problem you will never see. At a hundred thousand requests a day, that same race happens a hundred times, and every one of them is a support ticket or a corrupted record.

The uncomfortable part is that you inherit the higher denominator at exactly the moment things are going well. Growth converts your unlikely bug into a daily one, and it does it during the week you are least able to stop and fix it.

What actually makes something idempotent

There are three mechanisms, and picking the right one matters more than the code.

The cheapest is a natural uniqueness constraint. If the operation creates a record that already has a naturally unique identity, put a unique index on it and let the database reject the duplicate. A settlement keyed by provider reference, an enrolment keyed by student and cohort. The second attempt fails at the storage layer, you catch the conflict, and you return the existing record. No extra tables, no extra state.

The second is the conditional write, where the precondition lives inside the query rather than in an if statement before it. This is the pattern that also solves concurrency, which is not a coincidence, since both problems come from the gap between checking and acting.

The third, for operations with no natural key, is an explicit idempotency record created before the work begins.

The claim is written first, atomically. Do the work first and you have moved the race, not removed it.

The important detail there is the ordering. The claim is written first, atomically, and the work happens after. If you do the work first and record it afterwards, you have moved the race rather than removed it, because the second caller arrives during the gap.

The second important detail is the in progress state. A duplicate that arrives while the original is still running should not be allowed to proceed and should not silently return success either. Rejecting with a conflict is honest, and the client can retry once the first one lands.

Where I have needed it outside payments

Invitation emails, because a resend button and a retry both call the same function, and receiving the same invite four times makes a product feel broken in a way users mention.

Enrolment, because a student tapping an unresponsive button twice should occupy one seat, and seat counts feed capacity rules that decide whether a cohort is closed.

Certificate generation, because generating twice produces two documents with two identifiers, and a certificate identifier that is not unique defeats the entire point of having one.

File uploads, where a retry after a network drop otherwise leaves an orphan in storage that nothing references and nothing cleans up.

Anything driven by a queue, without exception, because at least once delivery means the queue is explicitly promising you duplicates.

Make it a default, not a decision

The habit that has served me best is to treat this as a property of the entry point rather than a feature of specific handlers.

Every route that mutates state gets asked one question during review. What happens if this is called twice with the same input. If the answer is that it is safe, that gets said out loud, ideally in a comment or a test. If the answer is that it is not, it does not merge without one of the three mechanisms above.

That question takes ten seconds to ask and it catches a class of bug that is extremely expensive to find later, because duplicates do not throw. They sit in the database looking like legitimate records, and you usually discover them during reconciliation, or when a user asks why they were charged for two of something.

The payments integration guide was right. It was just describing a general property of distributed systems while wearing a very specific costume.

I build EdTech and fintech products at E-Bringgs Technologies, where every externally reachable endpoint gets this question in review.