On this page
System Design — Digital Wallet
Last reviewed 11 Sept 2026
Part of the system design series. See the framework and building blocks first if you haven’t.
1. Requirements
Functional
- Top up a wallet from a linked card/bank account (external funding).
- Transfer stored value between two wallets (peer-to-peer, or wallet-to-merchant).
- Spend from the wallet at checkout.
- Withdraw/cash out to a linked bank account.
- View balance and transaction history.
Non-functional
- Never allow a negative balance from a race — two concurrent spends against the same wallet must not both succeed if only one has sufficient funds.
- Every operation must be idempotent — retries from the client or from an internal queue must not double-move money.
- Transfers must be atomic across two accounts (debit sender, credit receiver) — never leave one side applied without the other.
- Auditable and reconcilable against external funding sources (linked banks/cards) and against internal double-entry totals.
2. Where it sits / high-level architecture
This shares its core money-correctness machinery with the payment system — a digital wallet is a stored-value ledger sitting in front of (and sometimes behind) that payment layer, rather than a different discipline.
flowchart LR
App[Wallet client] -->|top-up| API[Wallet API]
App -->|transfer / spend| API
API --> IK[(Idempotency store)]
API --> TX{Operation type}
TX -->|top-up| PS[Payment system: charge card/bank]
PS -->|success webhook| CR[Credit wallet ledger]
TX -->|transfer/spend| DR[Debit sender + Credit receiver<br/>one local transaction]
CR --> Ledger[(Double-entry wallet ledger)]
DR --> Ledger
Ledger --> BalCache[(Cached balance per wallet)]
Recon[Reconciliation job] --> Ledger
Recon --> PS
- Top-up is not a wallet-internal operation — it’s a real external payment (card/bank charge) that, on success, credits the wallet ledger. It reuses the payment system’s idempotency and webhook-confirmation machinery rather than reinventing it.
- Internal transfers and spends stay entirely within the wallet ledger — no external PSP round trip, so they can be near-instant, but they still need the same atomicity and idempotency discipline because two internal accounts are moving in lockstep.
- The cached balance is a read-optimization over the ledger, refreshed on write and periodically reconciled against a full
SUM(ledger_entries)recomputation — never treated as more authoritative than the ledger itself.
3. Core design: concurrency control and ledger writes
| Concern | Approach | Trade-off |
|---|---|---|
| Prevent overdraft on concurrent spends | Conditional/optimistic update: UPDATE wallet_balance SET balance = balance - :amt WHERE wallet_id = :id AND balance >= :amt, 0 rows affected = insufficient funds | Serializes correctly under contention with no app-level lock, but a very hot single wallet (rare for consumer wallets, real for a platform’s central float account) can still bottleneck on row contention |
| Two-account transfer atomicity | Both the debit and the credit ledger rows write in one database transaction; never issue them as two separate calls | Limits a single transfer to accounts within the same shard/database unless a saga is used across shards |
| Idempotent transfer | Client-supplied transfer id (idempotency key) recorded with the transfer; a retried request with the same id returns the original result instead of moving money again | Same discipline as payments — key must be scoped per logical transfer, not per HTTP attempt |
| Balance representation | Ledger is the source of truth (SUM of entries); a wallets.balance column is a cache updated transactionally alongside the ledger write, periodically reconciled | Two writes per operation (ledger row + cache column) inside one transaction, not two separate transactions |
4. Deep dive
Optimistic concurrency on the hot wallet. Most consumer wallets rarely see true write contention — one person rarely spends from the same wallet twice in the same millisecond. But merchant or platform-level wallets (a ride-hailing driver’s earnings wallet during a payout batch, a marketplace’s central float account) can see genuine concurrent writes. The conditional-update pattern above handles this without locks by relying on the database’s own atomicity: the WHERE balance >= amount clause is evaluated and applied as one indivisible operation, so two concurrent spends against a wallet with only enough for one will have exactly one succeed and one see 0 rows affected — no distributed lock, no read-then-write race window. For genuinely hot central accounts, some systems go further and use an in-memory atomic counter (Redis DECRBY with a floor check via Lua, the same primitive as a rate limiter’s token bucket) as a fast-path guard in front of the database, reconciled back to it asynchronously.
Cross-shard transfers. Once wallets are sharded (necessary well before hundreds of millions of wallets fit on one database), a transfer between two wallets on different shards can no longer be one local ACID transaction. The standard answer is a saga: debit the sender’s shard in transaction 1, then credit the receiver’s shard in transaction 2; if transaction 2 fails, run a compensating transaction 3 that credits the sender back. This trades strict atomicity for eventual consistency with a bounded, logged recovery path — the transfer briefly exists in an “in-flight” state, which must be visible in the transfer’s own status (not silently invisible to the user) and swept by a background job if it’s stuck past an SLA, the same shape as the payment system’s reconciliation job.
5. What real systems do today
- Engineering guides on wallet architecture converge on the same trio used for payments: immutable double-entry ledgers, optimistic concurrency on balance updates, and the saga pattern for any operation spanning more than one account or service — explicitly framed as the pattern set that “turns chaotic distributed systems into the reliable financial plumbing” wallets need.
- PostgreSQL (and relational databases generally) remain the standard choice for the core wallet ledger even in 2026 writeups, despite the availability of NoSQL and blockchain-based alternatives — the ACID guarantees around a single debit+credit transaction matter more than horizontal write throughput for the volumes most wallets see, and sharding is applied at the account level rather than by weakening the transaction itself.
- Idempotency keys plus at-least-once delivery is the accepted way to reach “exactly-once” in practice — there is no true exactly-once over a network, so wallets (like payment systems) dedupe on the client-supplied operation id and make every consumer of a transfer event safe to run twice.
- Wallet-specific guides emphasize that top-up and cash-out are payment-system operations wearing a wallet UI — they go through the same PSP-adapter, webhook-confirmed, reconciliation-job machinery as any other card/bank charge, while purely internal transfers (wallet-to-wallet, wallet-to-merchant) skip the PSP round trip entirely and are correspondingly cheaper and faster.
6. Scaling & failure
| Bottleneck | Fix | New cost |
|---|---|---|
| One wallets table, all writes hit it | Shard by wallet/account id (consistent hashing) | Transfers crossing shards need a saga instead of one local transaction |
Balance recompute (SUM over full ledger) too slow at read time | Maintain a transactionally-updated cached balance column, reconciled against a full recompute periodically | Cache can theoretically drift from truth if a bug bypasses the transactional update path — the periodic reconciliation catches this |
| Hot central/platform wallet (payouts, float account) | Redis-backed atomic counter as a fast-path guard, reconciled to the ledger asynchronously | Adds a second system that must itself be reconciled — same trade-off as any cache-in-front-of-source-of-truth |
| Transaction history queries slow as history grows | Partition ledger by wallet id + time range; serve recent history from a hot partition, older from cold storage/archive | Very old history queries go through a slower path — acceptable for a rarely-hit case |
What happens when a cross-shard transfer’s second leg fails (saga compensation). The debit on the sender’s shard already committed. The credit on the receiver’s shard fails (timeout, shard unavailable, validation error). The saga’s compensating transaction credits the sender back the exact amount, and the transfer is marked failed with the compensation logged as its own ledger-visible event — not a silent rollback, because from the sender’s perspective money visibly left and came back, and that needs to be auditable. If the compensation itself fails (the sender’s shard is now also unavailable), the transfer sits in a stuck/needs-manual-reconciliation state that a background sweep and, ultimately, a human on-call process resolves — this is the honest answer, not a claim that sagas make failure impossible.
What happens when the ledger database (the wallet’s shard) is unreachable. Reads of balance/history degrade to the last cached value with a “may be stale” indicator rather than hard-failing the wallet UI. Writes (spend, transfer) must fail closed — never accept a spend the system can’t durably record, because unlike a read-only degradation, an unrecorded spend is an unrecoverable accounting gap. This is the same fail-closed-for-writes, fail-open-for-reads split that money-correctness systems generally need, as opposed to a protective system like a rate limiter where failing open is often the right default.
Interview follow-ups
- “How do you stop a wallet from going negative if two spends race?” — Conditional
UPDATE ... WHERE balance >= amount, same pattern as inventory oversell prevention; 0 rows affected means insufficient funds, no app-level lock needed. - “Walk me through a transfer between two wallets on different shards.” — A saga: debit shard A in one transaction, credit shard B in a second; on failure of the second, a compensating transaction refunds shard A, and the transfer’s status reflects the in-flight/failed/compensated state explicitly rather than hiding it.
- “Why is top-up different from an internal transfer, architecturally?” — Top-up is an external payment (card/bank) that only becomes a wallet credit on a confirmed webhook — it reuses the payment system’s async, PSP-adapter machinery; an internal transfer never leaves your own ledger and can be one local ACID transaction.
- “Client double-taps ‘send money’ — what stops a duplicate transfer?” — Client-generated idempotency key per logical transfer, stored with the result of the first execution; a repeat with the same key replays the stored outcome.
- “How do you keep the cached balance from silently drifting from the ledger?” — Update the cache inside the same transaction as the ledger write (never a separate write), and run a periodic reconciliation job that recomputes
SUM(ledger)and alerts/corrects on mismatch. - “The wallet database shard is down — what happens to a spend attempt?” — Fail closed: reject the spend rather than accept it without a durable record; reads can degrade to a stale cached balance, writes cannot.
- “How would this design change for a platform’s central payout wallet vs. a normal consumer wallet?” — The central wallet sees real write contention (many drivers/sellers paid out near-simultaneously); add an in-memory atomic-counter fast path (Redis, same primitive as a rate limiter) in front of the ledger, reconciled asynchronously, rather than relying purely on per-row database contention handling.
Sources: How Digital Wallets Work: A 2026 System Design Guide · Design a Digital Wallet System — The Design Round · Digital Wallet App Development: 2026 Infrastructure Guide — DashDevs · Payment System Design: Ledger, Idempotency, and Settlement — Ajit Singh · Design a Payment System: A Complete Guide (Updated 2026)