Skip to main content
Why Two Tabs Silently Overwrote Each Other: Field Notes on Lost Updates, Version Columns, and ETags

Why Two Tabs Silently Overwrote Each Other: Field Notes on Lost Updates, Version Columns, and ETags

September 27, 2026
Backend Engineering
10 min read

Headline: A lost update happens when two clients read the same record, both edit it, and the second save silently overwrites the first. Database transactions do not prevent it when the read and the write happen in separate HTTP requests; optimistic concurrency control does — an integer version column checked in the UPDATE's WHERE clause, exposed to HTTP clients as an ETag and If-Match header, and shown to the user as an explicit conflict instead of silent data loss.

Our team found this while testing an internal admin panel. We opened the same product in two browser tabs, changed the price in the first tab and saved, then changed the description in the second tab and saved. The description landed. The price change was gone. There was no error, no failed request, and no log line — the second form posted every field back, including the price it had loaded before the first save. Every write path used the ORM's default update, which is last-write-wins. These are our notes from making concurrent edits fail loudly instead.

Key takeaways

• A lost update is a read-modify-write race across requests: two clients load version 7 of a record, both save, and the later write erases the earlier one without any error.

• Transactions do not help across HTTP requests. The read happens in the request that renders the form and the write happens in a later POST, PATCH, or Server Action, so they are two separate, individually valid transactions — even at SERIALIZABLE isolation.

• Optimistic concurrency control is one extra predicate: an integer version column, an UPDATE with WHERE id = $1 AND version = $2, version = version + 1, and zero affected rows treated as a conflict.

• HTTP already has the vocabulary: send the version as an ETag, require If-Match on writes, and answer 412 Precondition Failed on a mismatch and 428 Precondition Required when the header is missing.

• A conflict is a UX decision, not a retry. Show the user what changed and let them reload or overwrite deliberately; retrying automatically with the fresh version recreates the silent overwrite.

What is a lost update, and why doesn't a database transaction prevent it?

A lost update is a concurrency bug in which two writers base their changes on the same original value and the second write discards the first writer's change. It is the classic read-modify-write race, and in web apps the "modify" step usually happens in a person's head, over seconds or minutes, between two HTTP requests.

A database transaction cannot prevent a lost update that spans HTTP requests, because no single transaction contains both the read and the write. The page that renders the edit form runs a SELECT in one short transaction and commits. Minutes later, the save handler runs an UPDATE in a second transaction. From PostgreSQL's point of view, two sequential transactions each updated a row, and nothing about that is anomalous. Raising the isolation level to REPEATABLE READ or SERIALIZABLE changes how overlapping transactions behave; it has nothing to say about transactions that never overlapped.

Not every write needs protection. When the change can be expressed in SQL, we let the database do the arithmetic atomically and skip the read entirely — for example, UPDATE products SET stock = stock - 1 WHERE id = $1 AND stock > 0. Counters, balances, and inventory fit that shape. Forms that a person edits do not, because the new value is whatever the person typed based on what they saw. That is where optimistic concurrency control belongs.

How do you add a version check with Drizzle or Prisma?

Optimistic concurrency control (OCC) is a strategy that lets writers proceed without locks and detects conflicts at write time by checking that the row still has the version the writer originally read. The implementation is an integer version column, defaulting to 1, and one extra condition in the WHERE clause of every update: UPDATE products SET name = $3, price = $4, version = version + 1 WHERE id = $1 AND version = $2.

In our Drizzle ORM helper, updateProduct calls db.update(products) with the new values plus version set to version + 1, filters with and(eq(products.id, id), eq(products.version, expectedVersion)), and reads the new version back with returning(). An empty result means the guarded write matched nothing, so the helper throws a ConflictError; otherwise it returns the new version number.

In Prisma, the equivalent is updateMany({ where: { id, version }, data: { ...input, version: { increment: 1 } } }), which returns { count }; a count of 0 is the conflict. Zero rows is ambiguous, though — the record may have been deleted rather than changed — so on a miss we run one follow-up SELECT to return a 404 for a missing row and a conflict for a stale version.

We use an integer rather than updatedAt as the version. PostgreSQL timestamptz stores microseconds while a JavaScript Date holds milliseconds, so a timestamp that round-trips through JSON can stop matching the stored value and produce false conflicts.

How do ETag and If-Match map onto a version column?

An ETag is an HTTP response header that identifies a specific version of a resource, and If-Match is the request header that makes a write conditional on the resource still having that ETag. Both are defined in RFC 9110, HTTP Semantics. A version column maps onto them directly: the GET handler sends the version as an ETag, the client echoes it in If-Match, and the server answers 412 Precondition Failed when the precondition no longer holds. 428 Precondition Required, defined in RFC 6585, rejects writes that omit If-Match, so no client can fall back to last-write-wins by accident.

In a Next.js Route Handler, GET returns the product with an ETag header containing the version in quotes, such as "7". PATCH reads the If-Match header, returns 428 when it is missing, strips the quotes, validates the body, and calls the same guarded update. Success returns 204 with the new ETag, and a ConflictError maps to 412.

Two details matter here. First, If-Match uses strong comparison, so a weak ETag such as W/"7" never matches; we send a strong "7". Second, the successful response returns the new ETag, so a client that keeps editing sends the right version next time instead of conflicting with its own previous save.

How do you pass the version through a Server Action form?

A Next.js Server Action invoked from a form cannot set custom request headers, so the version travels in the form body as a hidden field instead of in If-Match. Our saveProduct action reads the id and version from the FormData, validates the other fields, and runs the same guarded update. On success it calls revalidatePath and returns { status: 'saved', version }; on a ConflictError it returns { status: 'conflict', current } with the latest record, so useActionState can render a conflict panel instead of an error page.

The self-conflict bug is the first one we hit: after a successful save, the form still held the version it had loaded, so the second save from the same tab reported a conflict against itself. Rendering state.version ?? product.version into the hidden field fixed it.

The version field is not a security control. A client can always send the current version, which is exactly what an "Overwrite anyway" button does, so authentication and authorization still decide who may write the record at all. The version check protects honest users from races, not the app from attackers.

What should the user see when a conflict happens?

When a save hits a version conflict, the user should see that someone else changed the record, what they changed, and a choice between reloading the latest version and overwriting it on purpose. Our conflict panel lists each field where the server value now differs from the value the user originally loaded, next to the user's unsaved value. "Reload" discards the local edits; "Overwrite anyway" resubmits with the current version, which turns an accident into a decision.

A version conflict must never be retried automatically. A retry loop that refetches the version and resubmits is last-write-wins with extra round-trips. The one automatic resolution we allow is a field-level merge: if the fields the user changed and the fields the other writer changed do not overlap, the form applies the user's fields on top of the latest version and resubmits. That requires keeping the originally loaded snapshot on the client as a base to diff against, so we only built it for the largest form.

Optimistic concurrency also interacts with network retries. If a save succeeds but the response is lost, a client retry with the same version gets a 412 against its own successful write. An idempotency key on the save request, which we covered in our notes on retries and circuit breakers, lets the server replay the original success instead.

When should you use optimistic locking instead of SELECT ... FOR UPDATE?

Use optimistic locking when the read and the write are separated by user think-time, and pessimistic locking only when both happen inside one short server-side transaction. SELECT ... FOR UPDATE holds a row lock until the transaction ends, so it cannot span two HTTP requests, and holding a transaction open while a person edits a form would block other writers and tie up a pooled connection.

• Last-write-wins (ORM default): a blind UPDATE of every submitted field. Good fit: data one person owns, such as their own settings. Trade-off: silent data loss under concurrent edits.

• Atomic update: SET stock = stock - 1 WHERE stock > 0. Good fit: counters, balances, inventory. Trade-off: only works when the change is expressible in SQL.

• Optimistic concurrency (version column): WHERE version = $2, then version + 1. Good fit: human-edited records across HTTP requests. Trade-off: conflicts reach the user and need a UI.

• Pessimistic lock (SELECT ... FOR UPDATE): a row lock held until the transaction ends. Good fit: short read-modify-write inside one request or job. Trade-off: cannot span requests; lock waits and deadlocks.

• Edit lease ("someone is editing"): an application-level lock with an expiry. Good fit: long documents with one editor at a time. Trade-off: stale leases and extra state to manage.

Most CRUD apps end up with a mix: atomic updates for counters, optimistic concurrency for every human-edited form, row locks for a handful of server-side read-modify-write jobs, and last-write-wins only where one person owns the data.

FAQ

Q: Does a database transaction prevent lost updates?

A: Only when the read and the write happen inside the same transaction. In web apps the read happens when the form loads and the write happens in a later request, so each runs in its own transaction and the database sees nothing wrong. A version check in the UPDATE's WHERE clause is what detects the race.

Q: What HTTP status code should a version conflict return?

A: Return 412 Precondition Failed when an If-Match header does not match the current ETag, and 428 Precondition Required when a write omits If-Match. Return 409 Conflict when the version travels in the request body instead of a header, as with a Server Action form.

Q: Does optimistic concurrency apply to DELETE requests?

A: Yes. Require If-Match on DELETE and delete with WHERE id = $1 AND version = $2, so nobody deletes a record based on a stale view of it.

Q: When do I need CRDTs instead of optimistic locking?

A: When several people edit the same document at the same time and expect to see each other's changes live. CRDT libraries such as Yjs and Automerge merge concurrent edits instead of rejecting them; optimistic locking suits forms where simultaneous edits are rare and a conflict screen is acceptable.