A page-view counter. Reads the current count, adds one, writes it back. Under any concurrent load, increments get lost — two requests both see N, both write N+1, total ends up at N+1 instead of N+2.
The vulnerable diff
// metrics/page-view.ts
export async function recordView(id: string) {
// BUG: read-then-write counter, concurrent requests collide
const page = await db.pages.findUnique({ where: { id } });
await db.pages.update({
where: { id },
data: { views: page.views + 1 },
});
}What is wrong
The lost-update pattern is the lighter-weight cousin of TOCTOU on inventory. Every read-modify-write of a single counter without atomic guarantees produces lost updates under concurrency. Postgres serializes within a transaction, but each statement here is its own implicit transaction — no atomicity across the read and the write. The fix is to do the increment inside the UPDATE itself (`SET views = views + 1`), which holds the row lock between read and write atomically. This is the same shape as the inventory race fix but the bug class is 'count is wrong' rather than 'oversell'.
The attack
Reproducer: 100 parallel requests recording a view on the same page. The view count after all requests complete is typically ~30-60, not 100. Exact loss depends on transaction interleaving.
seq 1 100 | xargs -P 100 -I{} curl -X POST \
https://api.example.com/page-view/page-123
# Result: SELECT views: 47 (should be 100)In production, the bug looks like 'analytics under-counts on viral pages' — exactly the pages that matter most. Marketing complains, the data team adds a +2x fudge factor, nobody fixes the root cause for years.
Mesrai's review comment
mesraipilot · Bot · reviewed 1 min ago
[mesrai] [code-review] [Logic] [Lost-Update] [medium]
`SELECT views; UPDATE views = newViews` is a lost-update pattern.
Concurrent requests both read the same value, both write
old+1, one increment vanishes.
Fix: do the read+write atomically in the database:
await db.pages.update({
where: { id },
data: { views: { increment: 1 } },
});
Equivalent SQL: `UPDATE pages SET views = views + 1 WHERE id = ?`.
The row lock is held across the read inside the UPDATE statement,
so concurrent calls serialize.
For very high-write counters (>10k/s on a single row), use a
sharded counter or push to a streaming aggregator (Kafka + Materialize,
or Redis INCR + batch flush).The fix
// metrics/page-view.ts — fixed
export async function recordView(id: string) {
await db.pages.update({
where: { id },
data: { views: { increment: 1 } },
});
}One database round-trip instead of two. The UPDATE statement holds the row lock from read through write — concurrent calls block briefly, none lose. For genuinely high-write counters (popular product pages on Black Friday at 10k+ views/sec on one row), you also want sharded counters or batched writes through Redis — but for typical analytics workloads, the in-database increment is the right tool.
Why human review missed it
The pattern reads as correct application code. The bug is invisible at code review because the SELECT and the UPDATE both look right in isolation. The bug only manifests under concurrent load, which most tests do not exercise. Mesrai's rule pack catches every read-then-write on the same numeric field of the same row, and recommends the atomic increment form.
Related rules + further reading
Mesrai rule pack: logic/no-counter-read-modify-write — flags read-then-write on numeric fields.
Postgres docs: Concurrency Control — Explicit Locking.
Common cause of 'analytics under-counts' bug class in mature codebases.
Takeaway
Counter increments belong in the database statement. `SET views = views + 1` or the ORM `{ increment: 1 }`. One write, no lost updates.