Two requests try to pay out the same seller at the same moment. Both read a balance of 100, both decide that 80 is affordable, and both write. The account ends at -60. Nothing crashed and nothing was logged. The money is simply wrong.
Payout Ledger is my open-source payout monitor, and its ledger has one rule above the others: a balance never goes below zero, however requests interleave. This is how I chose to enforce it.
Three ways to do it
Row locks. Inside one transaction, lock the accounts involved, check the funds, write the entries. A second writer waits for the first to commit, then sees the new balance. The cost: writers on one account queue up, and locks taken in an inconsistent order can deadlock.
SERIALIZABLE isolation with retry. Let Postgres detect conflicting transactions and abort one; the application retries. The cost: every write path needs a retry loop, aborts rise under contention, and failures are harder to reason about.
Constraints only. Take no locks and let a CHECK on the balance reject the losing write. It is correct, but the loser gets a constraint error instead of a clean "insufficient funds", and the check happens late.
One fact narrowed the field. The app runs on serverless functions and reaches Postgres through a pooler in transaction mode, so session-level advisory locks are out: two consecutive statements may land on different connections. Whatever I chose had to live inside a single transaction.
What I chose
Row locks, with the constraint kept underneath as a safety net. Before writing, the application locks the affected accounts in a fixed order:
// Lock in a fixed order (by ID) so two transactions touching the same
// accounts can never wait on each other in a cycle.
const accounts = await tx.$queryRaw<LockedAccount[]>`
SELECT id, code, balance, allow_negative
FROM accounts
WHERE code = ANY(${codes})
ORDER BY id
FOR UPDATE`;The ordering is what prevents deadlocks. Two transactions that touch the same pair of accounts always ask for them in the same sequence, so neither can hold one lock while waiting for the other's.
Underneath sits the database's own rule. Each account's balance is maintained by a trigger on the ledger entries, so it cannot drift from them, and a constraint refuses an overdraft:
-- An account's balance may only go negative if explicitly allowed.
ALTER TABLE "accounts"
ADD CONSTRAINT "accounts_balance_non_negative"
CHECK ("allow_negative" OR "balance" >= 0);If application code forgets to lock, the constraint still holds. Correctness does not depend on every caller being right.
Proving it
The test runs against a real Postgres, not a mock. It funds a seller with 1,000 and fires 25 concurrent attempts to spend 100:
// 25 concurrent attempts to spend 100 from a balance of 1000.
const results = await Promise.allSettled(
Array.from({ length: 25 }, () =>
post(db, [
{ accountId: seller, amount: -100n },
{ accountId: external, amount: 100n },
]),
),
);
const succeeded = results.filter((r) => r.status === "fulfilled").length;
expect(succeeded).toBe(10);
expect(await balanceOf(seller)).toBe(0n);Exactly ten go through and the balance ends at zero.
What it costs
Writes to one account are serial. That is acceptable here, because contention is per seller, not global: one seller's payouts wait for each other, and nobody else's do.