How Does UPI Prevent Double-Spending at 15 Billion Transactions a Month?

How do you safely move money at massive scale without charging the customer twice?

Imagine this simple scenario:

You have ₹500 in your account and try to pay a merchant ₹400.

You tap Pay.

The network is slow.

You tap again.

Now two identical requests reach the backend almost simultaneously.

If the system isn’t designed correctly, both requests might see the ₹500 balance, both might authorize the payment, and you could end up with:

₹500 − ₹400 − ₹400 = −₹300

Obviously, a payment system cannot work this way.

At UPI-scale volumes—tens of billions of transactions over a month—the solution isn’t a single database lock or a simple if balance >= amount check.

It requires multiple layers of protection working together.


1. Where Does Double-Spending Come From?

There are two common problems.

Problem 1: The user retries the same payment

The user clicks Pay, but the response doesn’t arrive.

The app assumes the request failed and retries.

The backend receives:

Request #1 → Pay ₹400
Request #2 → Pay ₹400

Both requests may represent the same business transaction.

If the backend treats them as two independent transactions, the customer could potentially be charged twice.

This is why payment systems need idempotency.


Problem 2: Two different transactions race with each other

Consider:

Balance = ₹500

Transaction A → ₹400
Transaction B → ₹400

Without concurrency control, both transactions can execute:

Read balance
    ↓
₹500 >= ₹400
    ↓
Approve
    ↓
Deduct ₹400

Both transactions saw the same old balance.

The result can be incorrect.

This is a classic race condition.

So we need two different protections:

Idempotency prevents the same transaction from being processed twice.

Concurrency control prevents different transactions from incorrectly modifying the same account at the same time.


2. Idempotency Key — The First Line of Defense

Every payment request should carry a unique transaction or idempotency key.

For example:

transactionId = 7f3c9b2e-...

The client may retry the request multiple times, but all retries carry the same key.

The backend maintains an idempotency record:

IDEMPOTENCY_KEY
-------------------------------
key
status
response
created_at
expires_at

A fast distributed store such as Redis can be used for short-lived request coordination.

For example:

SETNX transactionId

If the key doesn’t exist:

SETNX → SUCCESS
        ↓
Process payment

If the key already exists:

SETNX → FAILURE
        ↓
Duplicate request
        ↓
Return existing result

The important principle is:

The same business transaction must produce the same outcome, regardless of how many times the client retries it.

But there is an important caveat

Simply doing:

SETNX → process payment

is not enough for a financial system.

What happens if the service crashes after SETNX but before the payment is committed?

You could end up with:

Idempotency key = exists
Payment          = not completed

Therefore, production systems generally need a durable transaction record and a well-defined state machine rather than relying solely on a Redis key.

Redis is excellent for fast duplicate detection, but the payment’s durable source of truth should be persistent.


3. Preventing the Race Condition

Idempotency doesn’t solve this problem:

Transaction A → ₹400
Transaction B → ₹400

These are two legitimate transactions.

They simply happen concurrently.

The balance update must therefore be atomic.

One traditional approach is a database row lock:

SELECT balance
FROM accounts
WHERE user_id = 123
FOR UPDATE;

The database locks the account row.

Then:

Check balance
      ↓
balance >= amount?
      ↓
Update balance
      ↓
Write transaction/ledger entry
      ↓
COMMIT

While Transaction A owns the lock, Transaction B waits.

After A commits, B sees the new balance.

For example:

Initial balance = ₹500

Transaction A
    Lock account
    Check ₹500 >= ₹400
    Deduct ₹400
    Commit

Transaction B
    Acquire lock
    See balance = ₹100
    Check ₹100 >= ₹400
    Reject

The race condition disappears.


4. Don’t Treat the Balance as the Only Source of Truth

A common beginner implementation is:

UPDATE accounts
SET balance = balance - 400
WHERE user_id = 123;

That isn’t enough for a financial system.

You also need a ledger.

A simplified double-entry model looks like:

Payment ₹400

Debit  → Customer account     ₹400
Credit → Merchant account     ₹400

Both entries belong to the same business transaction.

Conceptually:

                    Payment
                       │
              ┌────────┴────────┐
              ↓                 ↓
        Debit Customer    Credit Merchant
            ₹400               ₹400

The ledger should ideally be append-only and immutable.

Instead of modifying historical transactions, new entries represent subsequent adjustments, reversals, refunds, etc.

This provides an important capability:

The system can reconstruct and reconcile an account’s financial position from the ledger.

The balance can then be treated as a derived/current representation that must remain consistent with the ledger.


5. Double Entry Makes Reconciliation Possible

Suppose the system reports:

Customer balance = ₹10,000

But the ledger says:

Opening balance       ₹8,000
+ Credits             ₹5,000
- Debits              ₹3,000
-----------------------------
Calculated balance   ₹10,000

Everything matches.

Now imagine the database balance says:

₹10,000

but the ledger calculates:

₹9,600

That’s a red flag.

Something went wrong.

This is why financial systems need reconciliation, not just successful API responses.


6. Scaling to Billions of Transactions

At massive transaction volumes, putting everything into one database becomes a bottleneck.

A common scaling strategy is sharding.

For example:

hash(accountId) % N

might determine which shard owns an account.

Conceptually:

Account 123
    ↓
Hash
    ↓
Shard 5

All transactions involving that account are routed to the same shard.

This has another major benefit:

Concurrency control becomes easier when the account’s authoritative state lives on one shard.

Instead of trying to coordinate locks across multiple database instances, transactions affecting the same account can be serialized at its owning partition.


7. Where Does Kafka Fit?

Kafka is excellent for asynchronous processing and decoupling, but it shouldn’t be treated as the mechanism that guarantees financial correctness by itself.

A simplified architecture might look like:

             API Gateway
                  │
                  ▼
          Payment Service
                  │
          ┌───────┴────────┐
          │                │
          ▼                ▼
    Idempotency       Account/Payment
       Store             Database
                           │
                           ▼
                       Outbox
                           │
                           ▼
                         Kafka
                           │
              ┌────────────┼────────────┐
              ▼            ▼            ▼
           Bank/PSP     Notifications  Analytics
           Workers

The payment can first be validated and durably recorded.

After that, downstream processing can happen asynchronously.

Kafka provides:

  • Decoupling
  • Buffering
  • Horizontal scalability
  • Retry capabilities
  • Consumer parallelism
  • Replayability

But consumers must still be idempotent.

Kafka delivering a message twice should not result in money being moved twice.


8. What Happens When the Bank Doesn’t Respond?

This is where payment systems become significantly more interesting.

Suppose the request is sent to the downstream bank.

The connection times out.

Did the bank reject the transaction?

Or did the bank successfully process it but the response was lost?

You cannot safely assume failure.

A simplified state machine can be:

INIT
  │
  ▼
PROCESSING
  │
  ├──────────────► SUCCESS
  │
  ├──────────────► FAILED
  │
  └──────────────► PENDING

If the downstream system times out:

PROCESSING
     ↓
   PENDING

An asynchronous retry process can then query the downstream system using the same transaction/idempotency key.

Retry
  ↓
Check transaction status
  ↓
SUCCESS / FAILED / PENDING

This is much safer than:

Timeout → assume failed → retry as a new payment

because that could create a duplicate debit.


9. Reconciliation Is the Final Safety Net

Even a highly reliable distributed system can experience:

  • Network failures
  • Timeouts
  • Duplicate messages
  • Partial failures
  • Database failures
  • Consumer crashes
  • Downstream inconsistencies

That’s why payment systems need reconciliation.

A reconciliation process can periodically compare:

Internal Ledger
       vs
Bank/Network Settlement Data

For example:

Every hour
     ↓
Read internal transactions
     ↓
Compare with external settlement
     ↓
Match transaction IDs
     ↓
Identify discrepancies
     ↓
Alert / investigate

A simple duplicate detector can group transactions by their idempotency key:

transaction_key
       ↓
GROUP BY
       ↓
COUNT(*) > 1
       ↓
Investigate

Reconciliation doesn’t prevent the original problem.

It detects problems that escaped the primary controls.


10. Putting the Pieces Together

A robust payment architecture therefore looks something like:

                    ┌──────────────┐
                    │  API Gateway │
                    └──────┬───────┘
                           │
                           ▼
                 ┌──────────────────┐
                 │ Payment Service   │
                 └────────┬─────────┘
                          │
              ┌───────────┴───────────┐
              │                       │
              ▼                       ▼
        Idempotency              Account/Payment
           Store                   Database
              │                       │
              │                 ┌─────┴─────┐
              │                 │           │
              │                 ▼           ▼
              │              Balance      Ledger
              │
              │
              └──────────────┐
                             ▼
                           Outbox
                             │
                             ▼
                           Kafka
                             │
               ┌─────────────┼─────────────┐
               ▼             ▼             ▼
            Settlement   Notification   Analytics
               │
               ▼
          Bank / Network

                 + Reconciliation
                 + Monitoring
                 + Audit

Each component has a specific responsibility.

ProblemPrimary mechanism
Client retryIdempotency key
Duplicate requestIdempotency record
Concurrent balance updatesDB locking / atomic update
Financial auditabilityImmutable ledger
Large-scale dataSharding
Async processingKafka
Downstream timeoutPending state + status inquiry
Consumer retryConsumer idempotency
Data inconsistencyReconciliation
Operational visibilityMonitoring + audit trails

The Most Important Lesson

The key insight is that there isn’t one magic mechanism that prevents double-spending.

It is a combination of controls:

Idempotency
     +
Concurrency Control
     +
Atomic Financial Updates
     +
Double-Entry Ledger
     +
Durable State Machine
     +
Async Processing
     +
Reconciliation

Each solves a different failure mode.

A junior implementation might look like:

Check balance
     ↓
Deduct money
     ↓
Retry if something fails

That’s dangerous.

A production-grade design thinks more like:

Identify transaction
        ↓
Guarantee idempotency
        ↓
Serialize conflicting updates
        ↓
Atomically record financial state
        ↓
Write immutable ledger
        ↓
Publish durable events
        ↓
Process downstream asynchronously
        ↓
Handle uncertainty with PENDING
        ↓
Reconcile continuously

That’s the difference between “an API that moves money” and “a financial system that can be trusted.”


Final Thought

When someone says:

“UPI handles billions of transactions. How does it make sure a customer isn’t charged twice?”

The answer isn’t simply Redis, Kafka, database locks, or idempotency.

The real answer is defense in depth.

Every transaction passes through multiple layers designed around one fundamental principle:

A payment may be retried, delayed, duplicated, or partially failed—but the financial outcome must remain correct exactly once.

Leave a Reply

Your email address will not be published. Required fields are marked *