Featured image of post The €400 Wallet Problem: Concurrency, Lost Updates, and Where to Serialize

The €400 Wallet Problem: Concurrency, Lost Updates, and Where to Serialize

Two POS terminals charging the same benefit wallet at the same instant expose the classic lost-update problem. Four serialization strategies — pessimistic locks, optimistic versioning, partitioned events, and reservation ledgers — and the trade-offs payment backends actually face.

Benefit wallets, stored-value accounts, and employer-funded allowances share a constraint that card authorization alone does not solve: a finite spendable balance that multiple terminals can draw against at the same time. Two POS devices can send two valid authorization requests — different transaction IDs, different STANs, both well-formed — and still overspend the underlying wallet if the backend reads the balance before either write completes.

This is the classic lost update problem applied to payment ledgers. It is distinct from duplicate charging on retry, which is an idempotency problem when the same payment intent is submitted twice. Here the intents are different; the race is on shared mutable state. This post is really about one architectural question: where should serialization happen? Should concurrent access to the same wallet be serialized in the database, in the application, in the messaging layer, or in the domain model itself? The answer determines the trade-offs between throughput, latency, complexity, and correctness.

The scenario

An employee has a wellness allowance:

Wallet balance: €400

At the same instant, two POS terminals each submit a €300 charge against the same employee wallet. The requests are independent: different transaction IDs, different terminals, and both arriving concurrently. The requests are independent: different transaction IDs, different terminals, both arriving concurrently.

Without coordination, both transactions can execute:

SELECT balance  →  400
400 >= 300      →  YES (both)
UPDATE balance  →  100 (both write 100)

Both approvals succeed. Total spend: €600 from a €400 wallet.

The failure mode is not fraud at the terminal layer. It is read-modify-write without serialization on shared wallet state.

Why this is not solved by idempotency alone

Idempotency keys collapse duplicate submissions of the same payment intent. They do not protect against two different intents racing on the same balance.

In production, both mechanisms are required:

ProblemSame intent submitted twiceTwo different intents, one balance
Typical causeNetwork timeout, blind retryConcurrent terminals, parallel API calls
Primary defenseIdempotency key / STAN deduplicationSerialization on wallet or reservation row
Related postDouble chargesThis post

Point-of-Sale Systems Architecture, Ch. 15 (Backend Architecture for POS), treats idempotency at the API gateway and STAN/RRN deduplication at the ISO boundary as hard invariants. Wallet-backed products add a second invariant: no two concurrent debits may pass a balance check against the same stale read.

Option 1 — Pessimistic locking

The direct fix: lock the wallet row before reading and updating it.

BEGIN;

SELECT *
FROM wallet
WHERE wallet_id = 123
FOR UPDATE;

Transaction A acquires the row lock. Transaction B blocks until A commits.

Transaction A reads €400, debits €300, writes €100, commits. The lock releases. Transaction B wakes up, reads €100, checks 100 >= 300, declines.

Advantages

  • Simple to reason about inside a single database
  • ACID guarantees on the balance check and write
  • Overspend is impossible while the lock is held

Disadvantages

  • Throughput drops under contention — concurrent requests on the same wallet queue behind the lock
  • Lock duration must stay short; holding a lock across an external PSP call is dangerous
  • Hot wallets can become serialization bottlenecks because every update to the same wallet row must wait, even when the application is horizontally scaled

For a single PostgreSQL backend with moderate contention — one employee wallet receiving relatively infrequent concurrent requests — pessimistic locking is a defensible default. I would start here unless measurement proves contention is the bottleneck.

Option 2 — Optimistic locking

Add a version column to the wallet row:

balance = 400
version = 5

Transaction A:

UPDATE wallet
SET balance = 100, version = 6
WHERE wallet_id = 123 AND version = 5;

One row updated. Success.

Transaction B attempts the same WHERE version = 5 update. Zero rows affected. The application retries: re-reads balance (€100), re-evaluates, declines.

Advantages

  • No held locks during the read phase
  • Strong under low contention — most updates succeed on the first attempt

Disadvantages

  • Under heavy contention on the same wallet, retry storms increase latency and load
  • Application must implement retry logic with bounds (max attempts, backoff, decline on exhaustion)
  • Still requires a single authoritative store for the versioned row

Optimistic locking fits when wallet collisions are rare — many wallets, few concurrent writes per wallet. It is a weaker default when two terminals charging the same employee allowance is a normal Tuesday.

Option 3 — Serialize by partition key (event stream)

Move balance mutations behind ordered consumption by partitioning events on walletId. All commands for the same wallet are routed to the same partition, and that partition is owned by only one consumer in the consumer group at a time.

Instead of updating the wallet directly from two concurrent HTTP handlers, publish:

ReserveWallet { walletId: 123, amount: 300, paymentId: A }
ReserveWallet { walletId: 123, amount: 300, paymentId: B }

Partition the topic by walletId. Kafka (or any log with keyed partitioning) guarantees that all events for wallet 123 land on the same partition and are consumed in order by one consumer in the group.

The consumer processes sequentially:

400 → reserve 300 → 100 → reserve 300 → decline

No database row lock during the HTTP request path. The messaging layer establishes an ordered processing path for each wallet, enabling the consumer to serialize its balance mutations.

Advantages

  • Decouples the authorization API from the ledger write rate
  • Scales horizontally across wallets — wallet 456 and wallet 123 do not block each other
  • Natural fit for event-sourced or CQRS ledger designs

Disadvantages

  • End-to-end latency increases — the API must wait for the consumer result or accept async confirmation
  • Consumer failure, replay, and at-least-once delivery require idempotent handlers
  • Partition key choice is permanent for ordering; a wrong key shards related events incorrectly

This is the pattern I reach for when wallet volume grows beyond what a single database writer can sustain, or when the ledger is already event-driven. Point-of-Sale Systems Architecture, Ch. 15, notes synchronous replication and careful idempotency as prerequisites for zero-loss backends; partitioned consumption extends that model by making per-wallet ordering an explicit architectural choice rather than an accident of database locking.

Kafka does not eliminate the need for transactional correctness. It guarantees that all events for the same wallet are delivered to the same partition and processed by a single consumer within the consumer group. That ordering enables serialization, but the consumer must still perform the balance check and ledger update atomically within its own database transaction. Kafka solves ordering; PostgreSQL (or another transactional store) still protects correctness.

Option 4 — Reservation model

Many wallet and payment systems do not debit immediately. They reserve first, then capture or release.

Initial state:

Initial

Available: 400
Reserved: 0
Captured: 0


Reserve €300

Available: 100
Reserved: 300
Captured: 0


Capture

Available: 100
Reserved: 0
Captured: 300


(or)

Release

Available: 400
Reserved: 0
Captured: 0

Transaction A reserves €300:

Available: 100
Reserved:  300

Transaction B attempts to reserve €300 against available €100. Fails — regardless of whether the check uses a lock, a version column, or a serialized consumer.

Later, on payment success, reserved funds move to captured/settled. On failure or timeout, the reservation is released back to available.

This is the same structural pattern as pre-authorization and capture in card processing — an authorization hold reserves issuer open-to-buy without clearing funds. I cover the card-side hold lifecycle in Point-of-Sale Systems Architecture, Ch. 13 (Pre-Authorization and Completion Flows). Benefit wallets apply the idea on the merchant or platform ledger instead of the issuer account.

Advantages

  • Separates “funds committed for this attempt” from “funds permanently spent”
  • Timeouts and abandoned checkouts release reserved balance cleanly
  • Aligns with two-phase payment flows: reserve locally, call external PSP, capture or release

Disadvantages

  • More states to manage: pending, captured, released, expired
  • Requires a sweeper for stale reservations
  • Reservation and capture must both be idempotent under retry

Notice that these approaches are not mutually exclusive. A production payment platform may reserve funds using pessimistic locking, publish state changes through an outbox, serialize downstream processing by Kafka partition, and still use optimistic concurrency inside independent read models. Real systems rarely choose a single technique—they combine them at different architectural boundaries.

A reservation row with a unique (wallet_id, idempotency_key) constraint and a lifecycle of PENDING → CAPTURED | RELEASED | EXPIRED is the ledger pattern I would use for any wallet product that also calls an external payment provider. The wallet reservation is the correctness boundary; the PSP call cannot share that database transaction.

Why introduce reservations instead of debiting immediately? Because external payment providers sit outside your database transaction. The wallet cannot remain locked while waiting for a network call that might take seconds or fail entirely. Reserving funds establishes a local correctness boundary before calling the external provider. Once the provider responds, the reservation is either captured or released.

Which approach fits where?

There is no universal winner. The choice is where you want serialization to happen:

LayerMechanismBest when
DatabaseSELECT … FOR UPDATESingle Postgres, moderate contention, team wants simplicity
ApplicationOptimistic version retriesMany wallets, low per-wallet collision rate
MessagingPartition by walletIdHigh scale, event-driven ledger, async acceptance OK
Domain modelAvailable / reserved splitExternal PSP in the path, holds and timeouts matter

A reasonable evolution:

Correctness baseline:
PostgreSQL transaction
+ atomic balance check
+ wallet reservation

Scaling option:
Partition commands by walletId
+ ordered consumer processing

Reliable event propagation:
Transactional outbox
+ idempotent consumers

These are not necessarily sequential maturity stages. Reservations solve the business problem of committing funds across an external payment flow, while locking, versioning, and partitioned consumption determine how concurrent reservation attempts are serialized. An implementation may use reservations and PostgreSQL locking from day one, then introduce partitioned processing only when throughput requires it.

Connection to POS and backend design

Terminal-side correctness — EMV cryptograms, idempotent STAN on retry, reversal on timeout — does not protect the wallet ledger if the backend approves two concurrent debits. The POS did its job. The failure is backend concurrency.

Backend architects should verify:

  1. Balance checks and writes are atomic — same transaction, or serialized queue, or reservation with row lock.
  2. Idempotency keys are scoped per payment intent(wallet_id, idempotency_key) unique constraint.
  3. External PSP calls sit outside the lock — reserve locally, call provider, capture or release; never hold a wallet lock across network I/O.
  4. Cached balances are derived — the ledger or reservation table is source of truth; balance views are rebuildable.
  5. Reconciliation catches drift — compare ledger totals, reservation ages, and provider settlement files.

Ch. 6 (POS Application Architecture) emphasizes deterministic state machines and idempotency at the authorization client. Ch. 14 (Offline and Store-and-Forward) adds atomic queue operations and duplicate suppression on sync. Those terminal-side controls are necessary but not sufficient when the spend limit lives in a shared wallet.

Confirmed facts vs interpretation

Confirmed

  • Concurrent read-modify-write without serialization produces lost updates (standard database concurrency theory).
  • PostgreSQL SELECT … FOR UPDATE acquires a row-level lock until transaction end.
  • Kafka keyed partitioning delivers ordered consumption per partition within a consumer group.
  • Optimistic concurrency control uses a version column; conflicting updates affect zero rows and require application retry.
  • Pre-authorization in card processing reserves funds without immediate capture (scheme and issuer behavior; see Ch. 13).
  • Idempotency keys prevent duplicate processing of the same request; they do not serialize different concurrent requests against shared balance.

Interpretation

  • Pessimistic locking on PostgreSQL is an acceptable default for wallet backends at moderate scale before introducing streaming infrastructure.
  • Partition-by-walletId is the natural Staff-level evolution when a single writer becomes the bottleneck — not because Kafka is fashionable, but because per-wallet ordering is an explicit product requirement.
  • The reservation model is the correct domain shape when an external PSP sits in the path and “approved at the terminal” is not the same as “captured in the ledger.”
  • The interesting design question is not which technology wins, but where serialization belongs for your contention profile, latency budget, and failure modes.

Takeaway

Two terminals. One wallet. €400 available. Two €300 payment requests arriving at the same instant.

That scenario looks simple, yet it exposes one of the oldest problems in distributed systems: concurrent access to shared state.

The solution is rarely just “add a lock” or “use Kafka.” The real architectural decision is where serialization belongs:

  • A database lock serializes access at the storage layer.
  • Optimistic versioning detects conflicts and resolves them through bounded retries.
  • A partitioned event stream creates an ordered processing path for each wallet.
  • A reservation ledger commits business intent before external payment execution.

Most production payment platforms combine several of these mechanisms rather than relying on only one.

Good payment systems do not prevent concurrent requests. They make concurrent requests deterministic.

Idempotency stops the same payment intent from being processed twice. Serialization stops two different payment intents from spending the same balance concurrently. Payment backends need both.

References

Primary reference:

  • Bevia, V. Point-of-Sale Systems Architecture: A Practical Guide to Secure, Certifiable POS Systems. Book page. Chapters most relevant here:
    • Ch. 4 — Wallets and Alternative Payment Methods
    • Ch. 6 — POS Application Architecture (state machines, idempotency)
    • Ch. 13 — Pre-Authorization and Completion Flows (hold/reserve/capture pattern)
    • Ch. 14 — Offline and Store-and-Forward (atomic operations, duplicate suppression)
    • Ch. 15 — Backend Architecture for POS (idempotency invariants, transaction processor)
    • Ch. 19 — Case Studies (idempotency as non-negotiable)

Related Corebaseit posts:

External references:

  • PostgreSQL Documentation, Explicit LockingFOR UPDATE row-level locks
  • Apache Kafka Documentation, Partitioning and ordering guarantees
  • Kleppmann, M. Designing Data-Intensive Applications (O’Reilly) — Ch. 7 on transactions and Ch. 11 on stream processing (lost update, optimistic locking, partitioned logs)