Idempotency ensures that no matter how many times a request is made — the result stays the same... If the same ID comes again → it replies with the same result (not a new payment)
India’s UPI processes billions of transactions every day (over 21.7 billion in Jan 2026 alone), so the system must prevent any one action from executing twice. In practice, network hiccups or impatient taps can cause the same payment request to be sent multiple times. Without safeguards, this would double-charge users and cause chaos. To solve this, payment platforms rely on three key techniques: idempotency keys, distributed locks/atomic operations, and careful distributed transaction protocols (2PC/Saga). These ensure each payment intent yields a single, correct outcome.
The problem in one sentence
A payment request might arrive at the server more than once, but the money must move exactly once - never zero times, and never twice.
Double charges usually happen when one logical payment is retried or repeated by accident:
-
Network timeouts and automatic retries: The server processes the payment, but the client didn’t get the response, so it resends the request.
-
User impatience or error: A user taps Pay multiple times on a slow UI.
-
Lack of unique IDs: The client retries without a unique reference, making the server treat it as a new payment.
Each scenario has one intent (one payment) but multiple requests. The trick is to make these retries idempotent – i.e. harmless duplicates.
Layer 1: Idempotency keys: teaching the system to recognize a repeat
The very first defense happens before your request even reaches a bank's core ledger.
Idempotency keys are unique identifiers sent with each payment request. Think of them like a fingerprint for that transaction. The payment server or UPI switch checks: “Have I seen this transaction before?” If yes, it returns the same response as before; if not, it processes the payment normally. In short, the first request wins, later repeats yield cached results. This way, “click twice” still results in one payment.
-
One payment intent ⇒ one unique key ⇒ one final result. As one fintech guide explains: idempotency means “one payment intent, one unique reference, one consistent end result”.
-
Server-side check: The server maintains a record (in a database or cache) of each idempotency key for a certain window (e.g. 24–48 hours). On a retry, seeing the same key triggers a lookup: return the saved success or failure response instead of charging again.
For example, if you send two identical requests with idempotency key abc123, the first request creates the charge, and the second request simply returns the first result. The customer is only charged once, even though two requests arrived.
In practice, UPI assigns a unique transaction reference (TxnID or UUID) to each payment. NPCI or the banks store these IDs (often in a fast cache like Redis). If the same ID appears again within the reversal window, the system rejects it as a duplicate and returns the original outcome. Thus, network retries or double-taps never produce a second debit.
public PaymentResult processPayment(PaymentRequest request) {
String key = request.getIdempotencyKey();
if (idempotencyStore.exists(key)) {
return idempotencyStore.getResult(key); // Safe replay, no double debit
}
Lock lock = acquireLock(request.getAccountId());
PaymentResult result = debitAccountAndCreditRecipient(request);
idempotencyStore.save(key, result);
releaseLock(lock);
return result;
}Key insight: Idempotency isn't about preventing retries. Retries are expected and encouraged in distributed systems — the network will fail. Idempotency is about making retries safe by design.
Layer 2: Distributed locks: freezing the account mid-transaction
Even with a unique key, two servers can race. Imagine Server A and Server B both get the first request with the same idempotency key at nearly the same time. If they both check “has this key been used?” before either writes the record, both will think it’s new and each will process the payment – causing a duplicate charge.
To prevent this race, one can use distributed locks or atomic database inserts:
-
Distributed Lock (e.g. Redis): Before processing, a server attempts to acquire a lock on that key (for example,
SET key-xyz "processing" NX EX 60in Redis). Only the server that successfully acquires the lock proceeds; the other sees a failure and must abort or wait. In practice, a losing server would not process the payment again – it might immediately return a “processing” or “409 Conflict” response, then later return the stored result. This “try-lock-or-fail” pattern ensures only one server charges the account. (Care must be taken to set an expiration on locks and handle failures, or use robust algorithms like Redis Redlock.) -
Atomic DB Insert with UNIQUE: An alternative is to let the database do the work. Define a table of idempotency keys with a UNIQUE constraint on the key column. Then insert-first-then-check. For example:
INSERT INTO idempotency_keys (key, status, ...) VALUES ('abc123', 'processing', ...);
If two servers try this at the same time with the same key, only one INSERT succeeds; the other fails with a unique-constraint error. The winning server proceeds to charge and later updates the status. The loser catches the error, then simply queries the existing record to return the original result. This shifts the concurrency control to the database’s locking/transaction engine, often simplifying application logic.
Both methods achieve the same goal: they make the “check-then-act” step atomic across the system, so duplicate requests don’t slip through. In summary, idempotency plus locking (or atomic insert) is what guarantees “one click = one debit” even under heavy concurrency.
Example Flow Using Atomic Insert: For instance, a service handling payments might run code like:
-- Atomic check by insert
INSERT INTO payments (idempotency_key, ...)
VALUES ('txn-0001', ...)If this throws a “duplicate key” error, the service knows it has already processed this transaction and can fetch the previous response instead of charging again.
Layer 3: Two-phase commit and the saga pattern: getting two banks to agree
What about payments that involve multiple systems? (E.g., debiting one bank account and crediting another.) Two main paradigms exist:
-
Two-Phase Commit (2PC): A classic strong-consistency protocol. A coordinator asks each participant (e.g. different bank services) to “prepare” (can you commit?), then either tells all to “commit” or “rollback” as one group. 2PC ensures all-or-nothing across services, just like a local database transaction. However, it’s heavy: participants lock resources and wait for the coordinator. Any delay or failure can block the whole transaction or even require manual intervention. This approach gives ACID guarantees but hurts availability and speed.
-
Saga Pattern: An asynchronous approach popular in microservices. The global transaction is broken into a series of local transactions. Each step does its update and publishes an event to trigger the next step. If a step fails, the saga triggers compensating transactions that undo the earlier steps. For example, if a payment step fails, the saga might release reserved funds. Sagas favor eventual consistency: each service commits locally, and the system only reaches a consistent end state through these coordinated events. They are more complex to design (each operation needs an “undo” action), but they avoid the blocking drawbacks of 2PC.
|
2PC (Two-Phase Commit) |
Saga Pattern |
|
|---|---|---|
|
Consistency |
Strong (global ACID, all participants agree or abort) |
Eventual (local ACID + compensations for failure) |
|
Workflow |
Synchronous: prepare → commit/rollback phases |
Asynchronous: chain of local steps with messages/events |
|
Failure Handling |
Coordinator can block if a participant fails; requires rollback of all |
Compensating transactions undo partial work; no global lock |
|
Performance |
Higher latency, potential deadlocks, single point of failure |
More scalable (participants act independently), but complex logic |
|
Use Case |
Critical consistency needed (e.g. bank settlements) |
Long-running or distributed workflows (e.g. order + payment) |
In practice, many fintech systems avoid full 2PC across banks. Instead, each bank transaction is kept local-ACID, and the overall coordination uses event-driven or retry-based sagas. Whatever the pattern, idempotency remains crucial: every message or step must handle retries without side-effects (often via idempotent operations or locks).
What actually happens when your bank's server crashes mid-payment
This is the scenario everyone worries about, so it's worth walking through directly.
-
Your bank debits ₹500 and marks the transaction as "pending confirmation."
-
Before it can confirm to NPCI, the server crashes.
-
NPCI, waiting on a timeout, marks the transaction as unconfirmed rather than assuming success or failure.
-
A reconciliation job - running continuously in the background - compares your bank's ledger against NPCI's transaction log.
-
If your bank shows a debit but the recipient's bank shows no matching credit, the reconciliation process automatically reverses your debit.
This is why UPI refunds for failed transactions are usually automatic within a fixed window (commonly communicated as up to a few hours, sometimes longer for edge cases) rather than requiring you to file a complaint immediately - the system is designed to detect and self-correct these mismatches on its own, without a human in the loop for the common case.
The guarantee isn't "nothing ever goes wrong." It's "every inconsistent state is detected and automatically corrected." That distinction - self-healing over never-failing - is the actual foundation of reliable distributed systems.
Putting it all together
One completed UPI payment is really the output of three layers working in sequence:
-
Idempotency keys - make retries safe, so your double-tap never becomes a double debit.
-
Distributed locks - make sure only one write to your balance happens at a time, even under concurrent requests.
-
2PC / saga coordination - make sure two separate banks reach the same conclusion about a transaction, and automatically reconcile if they don't.
None of these ideas are unique to payments - they show up in e-commerce order processing, distributed databases, and microservice architectures everywhere. UPI is simply one of the highest-stakes, highest-scale places you'll find all three working together at once, processing billions of transactions a month with this exact discipline.

Discussion