Calin Gabriel Backend Developer · Node.js / TypeScript

← Lab Runs in your browser

Two hundred transfers, none of them failed

Three accounts holding 1,000 each. Two hundred transfers between them, all running at once. Every transfer reports success, nothing raises an error, and at the end the three accounts hold 3,296 instead of 3,000. This page runs that in your browser, then runs the versions that get it right and shows what each one costs.

Live demo PostgreSQL Isolation levels

What goes wrong

Moving money from one account to another is four steps: read the sender's balance, check there is enough, take the amount off one account, add it to the other.

Run two transfers at the same time and those steps overlap. The first reads a balance of 1,000. Before it writes anything back, the second reads the same 1,000. Both then write a new balance they worked out from it. The second write lands on top of the first, and the money the first transfer took out is back where it started — having already been delivered to the other account.

Nothing here is an error. Each write is a valid statement setting a column to a number, and a database has no way to know you worked that number out from something already out of date. What is wrong is the relationship between two writes, and no single statement can see that.

So it does not arrive as an incident. It arrives weeks later as an account that is short, in a log full of successful transfers with no error anywhere to explain it.

Show the six lines that do it
const from = await db.read(t.from);
const to = await db.read(t.to);
if (from.balance < t.amount) throw new Error("insufficient funds");

await db.write(t.from, from.balance - t.amount, from.version);
await db.write(t.to, to.balance + t.amount, to.version);

Every line is correct. The bug is in the gaps between them — four separate trips to the database, with other transfers running in between.

Run it

The accounts below are plain JavaScript objects, and every read and write waits a real turn of the event loop before it takes effect. That is all it takes: twenty transfers in flight at once genuinely interleave, so the lost updates here are lost the same way they are lost against a real server.

Both buttons run the same list of transfers. Only one of them ends with the money still adding up.

How many concurrent transfers

Loading the demo…

The third fix — serializable, and the retries it costs

Instead of locking anything, the database lets every transaction read freely and then checks at commit time whether a row it read has changed since. If one has, the transaction saw a version of the world that never existed and is thrown out with 40001. Nothing is wrong with your code; the contract is that you run the whole thing again.

Two transactions that are each correct and together wrong

Two doctors are on call. Either may go off call, provided somebody else is still on. Both check at the same moment, both see two doctors on call, both conclude it is fine, and each writes their own row. Nobody overwrote anybody. There is no doctor on call.

This is write skew, and it survives the fixes above: neither transaction touched a row the other touched, so watching for overwritten rows catches nothing. They only read what the other then changed. Serializable catches it because it tracks what a transaction read, not only what it wrote.

Deadlock, and the one line that fixes it

Locking only works if everyone agrees on an order. A transfer from account 1 to account 2 locks 1 then 2. One going the other way locks 2 then 1. Run both at once and each holds what the other is waiting for, so the database kills one with 40P01. The fix is to sort the accounts before locking them — any order, as long as everything uses the same one.

On Postgres that sort() was worth more than it sounds: the same 40 transfers took 70,504 ms with 142 deadlocks and 18 of them abandoned, against 96 ms and none. Postgres does not look for a deadlock cycle until a lock has waited a full second, and 142 of those seconds is where the 70 went.

The fixes

Three of them, in the order I would reach for them. They are not interchangeable, and the first is both the cheapest and the most limited.

01

Let the database do the arithmetic

UPDATE accounts SET balance = balance - $1 never sends a balance through the application, so there is no number that can go stale on the way. It was the cheapest fix measured here and the fastest. It only works when the new value is a function of the old one.

02

Lock the rows before reading them

SELECT … FOR UPDATE takes the locks up front and holds them until the end, so there is no gap left for anyone to write into. This is the one to reach for when the decision genuinely needs a read first. The cost is waiting.

03

Serializable, and retry

The database checks at commit whether the transaction could have run in some serial order, and throws it out if not. Correctness moves into the database; the job of running the whole thing again moves into your code. Skip the retry and most of the work never happens.

What the fixes cost

The browser demo is a model. These are measurements: PostgreSQL 16.15 in Docker, three accounts opening at 1,000 each, 200 concurrent transfers over a pool of 20 connections, each row one run of the same file at a different setting.

Version Wall Committed Thrown out by the DB Total after
Arithmetic in the app 176 ms 200 / 200 0 3,011
balance = balance - $1 170 ms 200 / 200 0 3,000
SELECT … FOR UPDATE 254 ms 200 / 200 0 3,000
Serializable, with retry 398 ms 137 / 200 659 3,000
Serializable, no retry 83 ms 26 / 200 174 3,000

The first row is the bug: fastest, no errors, wrong answer. The second is the one worth knowing — letting the database do the arithmetic was correct and as fast as the broken version, 170 ms against 176. When it fits, it costs nothing.

Serializable is the row worth sitting with. It produced the right total by throwing out 659 transactions to get 200 transfers done, and 63 of those transfers never happened at all — they ran out of attempts. Without the retry wrapper it commits 26 of 200. The money is correct in every case; whether the work got done is a separate question, and the isolation level does not answer it for you.

This is a deliberately cruel workload — every transfer touches two of only three rows, so almost everything conflicts with almost everything. Spread the same transfers over ten thousand accounts and the retry rate falls close to nothing.

What this does not prove

The thing in your browser is not Postgres. It has no snapshots, no write-ahead log, no planner and no disk. It models the interleaving of reads and writes, and one commit-time rule, because that is the part the page is about.

It also overstates the damage. Postgres locks a row for the duration of an UPDATE even with no transaction around it, which leaves a narrow window; the model locks nothing, so its window is the whole gap between a read and a write. That is the difference between the browser losing about 300 out of 3,000 and Postgres losing 11.

And the browser is reproducible in a way the database is not. Its ordering comes out the same every run, because nothing in it waits on anything real. Five consecutive runs of the identical 200 transfers against Postgres finished at 3,005, 3,008, 3,009, 3,013, 3,019 — same code, same input, a different wrong answer each time. That is what makes this kind of bug hard to reproduce from a ticket.

Where this came from

This is interview preparation, published. I wrote the SQL scenarios and the Node lab to answer isolation-level questions properly rather than from memory, and turning them into something runnable was the part that made them stick.

The interest is not abstract: my last role was backend work on an institutional crypto-custody platform for banks, where concurrent access to balances is the kind of thing the product is judged on. I did not build the ledger there — I owned the address book, worked on authentication between services, and wrote tests — so nothing on this page is a description of that work. It is what I went and learned afterwards.