Money Under Concurrency: Optimistic vs Pessimistic Locking

Published: August 15, 2025
Updated: June 6, 2026
11 min read

🎯

The lost update problem can mint or destroy money under concurrent access. Pessimistic locking, optimistic locking, write skew and SERIALIZABLE, and why append-only ledgers sidestep the whole race.

Introduction

Two requests hit your API at the same millisecond. Both want to withdraw 80,000 VND from an account holding 100,000. Each reads the balance (100,000), each checks "is there enough? yes," each subtracts 80,000, each writes the result. The account ends at 20,000 — but 160,000 left it. You just created 60,000 VND out of nothing, and no individual line of code did anything wrong.

This is the lost update problem, and in money systems it's not a theoretical concern — it's the difference between a correct ledger and a fraudulent one. Concurrency is where naive financial code quietly breaks, because the bug only appears under simultaneous access and never shows up in a single-threaded test. Having debugged exactly these races in a fintech ledger, I want to lay out the two tools that solve them — pessimistic and optimistic locking — when each fits, and the sharper failure (write skew) that neither fully fixes.

The Lost Update Problem

The race above is the canonical lost update: two transactions read the same value, both compute a new value from what they read, and the second write overwrites the first as if it never happened. The interleaving looks like this:

T1: read balance = 100000
T2: read balance = 100000          ← both saw the same starting point
T1: write balance = 100000 - 80000 = 20000
T2: write balance = 100000 - 80000 = 20000   ← T1's deduction vanished

The defining feature: each transaction's update is based on a value it read earlier, and that value went stale before the write. Any "read, compute, write" sequence on shared data is vulnerable. The fix is to stop the two transactions from operating on the same stale snapshot — and there are two philosophies for doing that.

Pessimistic Locking: Assume Conflict, Lock First

Pessimistic locking takes the view that conflicts are likely, so it locks the row before touching it and forces other transactions to wait. In Postgres, that's SELECT … FOR UPDATE:

await db.$transaction(async (tx) => {
  // Acquire a row lock; any other FOR UPDATE on this row blocks here.
  const account = await tx.$queryRaw`
    SELECT balance FROM accounts WHERE id = ${id} FOR UPDATE
  `
  if (account.balance < amount) throw new InsufficientFundsError()

  await tx.$queryRaw`
    UPDATE accounts SET balance = balance - ${amount} WHERE id = ${id}
  `
})

When T1 runs SELECT … FOR UPDATE, it holds the lock until it commits. T2's SELECT … FOR UPDATE blocks until T1 is done, then reads the already-updated 20,000 balance, correctly sees there aren't funds for another 80,000, and rejects. The lost update is impossible because the two never overlap on a stale read.

Strengths: correct and intuitive; the transaction that holds the lock is guaranteed an uncontested view. Costs: it serialises access to hot rows (every transfer touching the same account queues up), which limits throughput; and holding locks across slow work invites contention and deadlocks. Use it when conflicts are frequent and the locked critical section is short — debiting a single account balance is the textbook case.

Optimistic Locking: Assume Success, Verify at Write

Optimistic locking bets that conflicts are rare, so it takes no lock. Instead it reads a version number along with the data, does its work, and at write time asserts the version hasn't changed. If it has, someone else got there first, and this transaction retries.

ALTER TABLE accounts ADD COLUMN version INTEGER NOT NULL DEFAULT 0;
async function withdraw(id: string, amount: number) {
  for (let attempt = 0; attempt < 3; attempt++) {
    const acct = await db.account.findUnique({ where: { id } })
    if (acct.balance < amount) throw new InsufficientFundsError()

    // Update ONLY if the version is still what we read.
    const { count } = await db.account.updateMany({
      where: { id, version: acct.version },     // ← the optimistic check
      data: {
        balance: acct.balance - amount,
        version: { increment: 1 },
      },
    })

    if (count === 1) return        // success: our version was current
    // count === 0: someone else updated first → retry with fresh data
  }
  throw new ConflictError('Too much contention, please retry')
}

If T1 and T2 both read version 5, T1 writes first and bumps it to 6. T2's updateMany with version: 5 matches zero rows — its update is rejected, it loops, re-reads version 6 and the new balance, and proceeds correctly. No locks were held; conflicts are detected, not prevented.

Strengths: no blocking, so it scales beautifully when contention is low; no risk of one slow transaction stalling others. Costs: under high contention it degrades — transactions keep colliding and retrying, wasting work. Use it when conflicts are rare (most accounts aren't being hammered simultaneously) or when the critical section spans a user think-time you'd never want to hold a DB lock across.

Choosing Between Them

Pessimistic (FOR UPDATE) Optimistic (version)
Assumes conflicts likely conflicts rare
Mechanism lock + block check version + retry
Best when high contention, short critical section low contention, or long/think-time gaps
Failure mode lock contention, deadlocks retry storms under high contention

A practical rule: for a single hot balance row updated in a fast transaction, pessimistic is simplest and correct. For updates that span a user interaction (read on one request, write on a later one), optimistic is the only sane choice — you can't hold a database lock across a user's coffee break.

The Harder Problem: Write Skew

Both techniques above protect a single row. But some money bugs span multiple rows in a way neither catches. Suppose a rule says "the sum of two linked sub-accounts must stay ≥ 0." Two transactions each read both balances, each confirms the combined total stays valid after its own withdrawal, and each writes a different row. Individually each is fine; together they violate the invariant. Neither holds a conflicting lock (different rows), and neither's version changed (different rows). This is write skew, and it's the subtle one.

The clean fix is the strongest isolation level, SERIALIZABLE, which makes Postgres detect that the two transactions' read/write sets conflict and aborts one:

await db.$transaction(async (tx) => { /* ... */ },
  { isolationLevel: 'Serializable' })

Under SERIALIZABLE, you must be ready to catch serialization failures and retry — the database trades "never wrong" for "occasionally asks you to try again." For invariants that span rows, that trade is worth it; for single-row balance updates, it's overkill.

Append-Only Ledgers Sidestep the Whole Problem

The most robust financial systems often avoid mutable balances entirely. Instead of UPDATE balance, they append immutable entries to a ledger and derive the balance as a sum:

INSERT INTO ledger_entries (account_id, amount, type) VALUES (...);
-- balance = SELECT SUM(amount) FROM ledger_entries WHERE account_id = ...

Appends don't conflict the way updates do — there's no shared cell being overwritten — which removes the lost-update race at the source. You still enforce "sufficient funds" with a check (often a FOR UPDATE on the account's current-balance row, or a SERIALIZABLE check), but the audit trail is perfect and the data model is naturally concurrency-friendly. It's why ledgers, not balances, are the standard representation of money.

Pitfalls

  • Read-modify-write outside a transaction. Reading the balance in one query and writing in another, uncoordinated, is the lost update in its purest form.
  • Holding pessimistic locks across network calls. Lock, call a slow external API, then write — and you've serialised everyone behind a remote service. Keep critical sections tight.
  • Optimistic locking without a retry cap. Infinite retries under contention turn a slow path into an outage. Bound them and surface a clean error.
  • Floating-point money. Concurrency aside, store amounts as integers in the smallest unit; float rounding corrupts sums regardless of locking.
  • Assuming the default isolation level prevents write skew. It doesn't. Read Committed (Postgres default) allows it; you need SERIALIZABLE or explicit locking of the rows the invariant depends on.

Conclusion

Concurrency bugs in money systems are insidious because the code is correct in isolation and only wrong when two copies run at once — which your tests rarely do and production always does. Pessimistic locking prevents conflicts by serialising access; optimistic locking detects conflicts and retries; the choice between them is really a bet on how often two transactions will actually collide. And when an invariant spans multiple rows, write skew reminds you that row-level tools have limits — that's where SERIALIZABLE earns its cost.

The deeper lesson from building ledgers: model money as append-only entries, keep critical sections short, store amounts as integers, and always assume two of everything are happening at once. Get the concurrency model right and the balance is always real; get it wrong and you mint or destroy money silently. The same "assume it happens twice" discipline runs through idempotent processing and is what makes reconciliation able to prove the books are correct.