Blog
Last Write Wins. That's the Bug.
- software-engineering
- laravel
- typescript
- databases
The stock count says 3. Two customers check out at the same moment. Both requests read 3, both subtract one, both save 2.
You sold two. The database says you sold one.
Nothing threw. No log line looks weird. The number is just wrong, and it stays wrong until somebody counts the shelf.
That's a lost update. It's one of the quietest bugs you can ship.
Read, change, save
Most code that updates a row looks like this:
$product = Product::find($id);
$product->stock = $product->stock - $qty;
$product->save();Fine on your laptop. In production, two requests can both run line one before either runs line three. Each one does its math on the same stale value, and whichever saves last wins. The other one's work is just gone.
Tests won't catch it because tests run one thing at a time. Local won't catch it because you're the only user. Production catches it on your busiest day, which is exactly when you don't want to be reconciling numbers.
Let the database do the math
If the change is arithmetic, don't do it in PHP. Send the database the operation, not the answer.
Product::whereKey($id)->decrement('stock', $qty);That runs stock = stock - ? in a single UPDATE. The database applies it to whatever the current value is, so two of them really do subtract two.
Usually there's a rule too, like "don't go below zero." Put it in the same statement:
$updated = Product::whereKey($id)
->where('stock', '>=', $qty)
->decrement('stock', $qty);
if ($updated === 0) {
throw new OutOfStock($id);
}The check and the write happen together. There's no gap for another request to squeeze into. If zero rows changed, somebody got there first, and now you know right away instead of next week.
Same idea with Prisma:
const { count } = await db.product.updateMany({
where: { id: productId, stock: { gte: qty } },
data: { stock: { decrement: qty } },
});
if (count === 0) {
throw new OutOfStockError(productId);
}updateMany earns its spot here because it returns how many rows it touched. That count is your answer.
Lock the row when it won't fit in one query
Sometimes the update depends on more than one value, or writes to another table, and you can't squash it into a single statement. That's when you lock.
DB::transaction(function () use ($accountId, $amountCents) {
$account = Account::whereKey($accountId)
->lockForUpdate()
->firstOrFail();
if ($account->balance_cents < $amountCents) {
throw new InsufficientFunds($accountId);
}
$account->balance_cents -= $amountCents;
$account->save();
$account->ledgerEntries()->create(['amount_cents' => -$amountCents]);
});lockForUpdate() makes the second request wait at that line until the first transaction commits. When it finally reads, it gets the fresh balance, not the stale one.
A few rules that keep this from hurting:
- It only means something inside a transaction.
- Keep the transaction short. No HTTP calls, no emails, nothing slow in the middle, because every other request touching that row is waiting on you.
- Lock rows in the same order everywhere. Two transactions grabbing the same rows in opposite order is how you get a deadlock.
When there's no row to lock
Sometimes the thing you're protecting isn't a row. "Only one sync per customer at a time." "Don't build the same export twice at once." That's what atomic locks are for:
Cache::lock("sync:customer:{$customerId}", 120)
->block(10, fn () => $this->syncCustomer($customerId));It waits up to 10 seconds to get the lock, holds it for at most 120, and releases it when the callback finishes. If it can't get the lock in time, it throws a LockTimeoutException. That's a lot better than quietly running twice.
Two catches. You need a cache driver that supports atomic locks, like Redis or the database driver. And the expiry has to be longer than the work actually takes, or the lock runs out halfway through and the next request walks right in.
When the edit takes minutes, not milliseconds
The sneakiest version has a human in it. Two people open the same record. One saves. The other saves ten minutes later from their stale copy, and the first person's changes vanish.
You can't hold a database lock across someone's coffee break. So use a version number instead. Send it with the form, and only save if it still matches:
const { count } = await db.document.updateMany({
where: { id: input.id, version: input.version },
data: {
title: input.title,
body: input.body,
version: { increment: 1 },
},
});
if (count === 0) {
throw new ConflictError("This record changed since you opened it.");
}Return a 409 and let the frontend tell them it changed. Showing someone a conflict is annoying. Silently deleting their coworker's edits is worse.
The habit
Whenever you see read, change, save, ask: what if another request does the same thing between my read and my save?
If the change is math, push it into the query. If it's logic, lock the row and keep the transaction short. If there's no row, use an atomic lock. If a person is in the middle, check a version.
And if you're not sure, at least make the update conditional and check the row count. Zero rows changed is the database telling you something. Listen to it.
Last write wins is a fine policy for a settings toggle. For stock, balances, and anything someone spent twenty minutes typing, it's a bug with good manners.