Mesrai
Back to blog
// essayTechnical Deep Dive

Lost-Update on Counter Increment: The UPDATE x SET n = n + 1 Pattern

Real PR catch: read-then-write counter increment — concurrent requests lose increments. Atomic UPDATE fix.

Mesrai TeamJuly 29, 20268 min read

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

tsmetrics/page-view.ts
// 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.

bashexploit
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

textreview-comment.txt
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

tsmetrics/page-view.ts (fixed)
// 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.

// try

See it on your next PR.

Free for individuals. Install in two minutes. Mesrai reviews every commit.