Engineering Resilience: Preventing Silent Data Loss with Optimistic Concurrency Control, ETags, and Version Checks

Share
Engineering Resilience: Preventing Silent Data Loss with Optimistic Concurrency Control, ETags, and Version Checks

Executive Overview

In the realm of modern web development, silent data loss remains one of the most insidious and difficult-to-detect bugs plaguing distributed applications. Among these, the "lost update" anomaly stands out as a critical hazard. This concurrency flaw occurs when two or more clients read the same database record simultaneously, independently modify its properties, and persist their changes sequentially.

Because standard ORM implementations rely on a "last-write-wins" paradigm, the second save routinely overwrites the first without throwing an exception, logging a warning, or alerting the user. The initial modifications vanish into the ether, leaving application maintainers and end-users unaware that vital data has been quietly erased.

Traditional database transactions—often considered a silver bullet for data integrity—fundamentally fail to prevent lost updates when the application lifecycle spans multiple HTTP requests. Because the "read" phase occurs during initial page rendering and the "write" phase happens minutes (or hours) later upon form submission, the operations reside in entirely separate transactions. To the database management system (DBMS), these are simply two valid, non-overlapping updates, rendering isolation levels like REPEATABLE READ or SERIALIZABLE utterly ineffective.

To combat this, elite engineering teams turn to Optimistic Concurrency Control (OCC). By embedding an integer version column into database schemas, mapping those versions directly to HTTP standards like ETag and If-Match headers, or passing them through framework-level server actions, developers can transform silent data corruption into explicit, manageable conflicts. This comprehensive field report explores the mechanics of lost updates, dissects real-world code implementations using Drizzle ORM and Next.js, and provides a strategic framework for deciding when to deploy optimistic locking versus alternative concurrency patterns.


Detailed Chronology: Anatomy of a Concurrency Failure

The discovery of architectural vulnerabilities often begins in the trenches of day-to-day quality assurance. The realization of this specific flaw came to light during the testing of a newly constructed administrative control panel. The diagnostic scenario was deceptively simple:

  1. The Initial Read: An administrator opened a single product record in two distinct browser tabs simultaneously. Both tabs loaded identical states, including a product price of $50 and a baseline description.
  2. The First Modification: In the first tab, the user updated the product price to $45 and clicked "Save." The application processed the request, executed a standard ORM update, and successfully committed the change to the database.
  3. The Second Modification: Moving to the second tab—unaware that the price had already changed—the user revised the product description, leaving the stale price field untouched at $50, and clicked "Save."
  4. The Silent Failure: The second form posted every field back to the server, including the obsolete price. The application’s ORM executed a blind UPDATE statement, overwriting the database row with the stale price value.

The $45 price change vanished completely. There was no runtime error, no failed HTTP status code, and not a single line in the application logs indicating that data had been lost.

This incident prompted a deep dive into how web frameworks, ORMs, and relational databases handle concurrent modifications. The subsequent investigation revealed that default ORM behaviors are inherently hazardous for multi-tab or multi-user editing environments. To solve this permanently, engineers must bridge the gap between application-level state and database-level validation.


Supporting Context & Metrics: Understanding Concurrency Strategies

Not every write operation within an application requires heavy-duty concurrency control. Software architects must carefully evaluate the shape of the data and the nature of the write operation before selecting a mitigation strategy.

1. Atomic Updates vs. Human-Edited Forms

When an operation can be expressed entirely in SQL arithmetic, applications should bypass the application-level read phase entirely. Consider an inventory stock reduction:

-- Atomic update: the database executes the arithmetic natively
UPDATE products
SET stock = stock - 1
WHERE id = $1 AND stock > 0;

This pattern is ideal for counters, financial balances, and inventory counts. The database handles the race condition atomically at the engine level. However, human-edited web forms do not fit this model. The new value of a form field is determined by a human user based on what they visually perceived on their screen at a specific moment in time. This cognitive "think-time" separates the read from the write, necessitating Optimistic Concurrency Control.

2. Comparative Analysis of Concurrency Paradigms

Strategy Mechanism Optimal Use Case Primary Trade-Off
Last-Write-Wins (ORM Default) Blind UPDATE of every submitted form field. Single-owner data (e.g., user profile settings). Silent data loss under concurrent edits.
Atomic Update Direct SQL arithmetic (SET stock = stock - 1). Counters, balances, inventory systems. Restricted to changes expressible in pure SQL.
Optimistic Concurrency (OCC) Version column check in WHERE clause (version = $2). Human-edited records spanning multiple HTTP requests. Conflicts surface to the user, requiring a dedicated UI.
Pessimistic Locking (FOR UPDATE) Row-level lock held until transaction termination. Short read-modify-write loops inside a single request. Cannot span HTTP requests; high risk of lock waits and deadlocks.
Edit Leases Application-level locks with expiration timestamps. Long-form documents restricted to a single active editor. Overhead of managing stale leases and background timers.

Technical Implementation: Enforcing OCC with Drizzle, Prisma, and HTTP Semantics

To make concurrent edits fail loudly, developers must implement a version-checking mechanism. Below is a breakdown of how to integrate this pattern across the database and application layers.

Implementing OCC in Drizzle ORM

The foundational principle of OCC is that writers proceed without acquiring locks. Conflicts are detected strictly at write time by verifying that the target row still possesses the exact version number the writer originally read.

import  and, eq, sql  from 'drizzle-orm';
import  db  from '@/db';
import  products  from '@/db/schema';

export class ConflictError extends Error 

export async function updateProduct(
  id: string,
  expectedVersion: number,
  input:  name: string; price: number; description: string ,
) 
  const rows = await db
    .update(products)
    .set( ...input, version: sql`$products.version + 1` )
    .where(and(eq(products.id, id), eq(products.version, expectedVersion)))
    .returning( version: products.version );

  if (rows.length === 0) 
    throw new ConflictError(`Product $id is no longer at version $expectedVersion`);
  
  return rows[0].version;

If another process or user bumps the version number before the transaction commits, the WHERE clause matches zero rows, and no data is written. (Note: For Prisma users, the equivalent pattern uses updateMany checking id and version, where a returned count of 0 indicates a conflict).

Engineering Caveat: Always run a quick follow-up verification on a zero-row miss to distinguish between a genuine version conflict and a 404 Not Found error resulting from a concurrent deletion. Furthermore, avoid using updatedAt timestamps as version tokens; PostgreSQL timestamptz stores microseconds, whereas JavaScript Date objects hold milliseconds, which frequently leads to serialization discrepancies and false conflicts.

Mapping ETags and If-Match Headers

In standard RESTful APIs, HTTP semantics provide built-in mechanisms for optimistic concurrency via ETag and If-Match headers (defined in RFC 9110).

// app/api/products/[id]/route.ts
import  getProduct, updateProduct, productSchema, ConflictError  from '@/lib/products';

type Ctx =  params: Promise< id: string > ;

export async function GET(_req: Request,  params : Ctx) 
  const  id  = await params;
  const product = await getProduct(id);
  if (!product) return new Response(null,  status: 404 );
  return Response.json(product,  headers:  ETag: `"$product.version"`  );


export async function PATCH(req: Request,  params : Ctx) "$/g, ''));
  if (!Number.isInteger(expected)) return new Response(null,  status: 412 );

  const input = productSchema.parse(await req.json());
  try 
    const version = await updateProduct(id, expected, input);
    return new Response(null,  status: 204, headers:  ETag: `"$version"`  );
   catch (err) 
    if (err instanceof ConflictError) return new Response(null,  status: 412 );
    throw err;
  

Under this architecture, the GET handler exposes the version as a strong ETag. The client echoes this value back in the If-Match request header during a PATCH operation. If the versions diverge, the server responds with 412 Precondition Failed. If the client omits the header entirely, the server responds with 428 Precondition Required (RFC 6585), preventing clients from accidentally falling back to unsafe, last-write-wins behavior.

Handling Versions in Next.js Server Actions

When utilizing framework-level abstractions like Next.js Server Actions, custom request headers cannot always be attached directly. Instead, the version travels securely as a hidden form field.

'use server';
import  revalidatePath  from 'next/cache';
import  getProduct, updateProduct, productSchema, ConflictError, type Product  from '@/lib/products';

export type SaveState =  'saved' ;

export async function saveProduct(_prev: SaveState, formData: FormData): Promise<SaveState> 
  const id = String(formData.get('id'));
  const version = Number(formData.get('version'));
  const input = productSchema.parse(Object.fromEntries(formData));

  try 
    const next = await updateProduct(id, version, input);
    revalidatePath(`/products/$id`);
    return  status: 'saved', version: next ;
   catch (err) 
    if (err instanceof ConflictError) 
      return  status: 'conflict', current: (await getProduct(id)) ?? undefined ;
    
    throw err;
  

On the client side, developers must ensure that the hidden version input updates dynamically following a successful submission to prevent "self-conflicts"—a scenario where a user saves twice in succession from the same tab, but the form retains the initial, stale version token.

const [state, formAction, pending] = useActionState(saveProduct,  status: 'idle' );

<form action=formAction>
  <input type="hidden" name="id" value=product.id />
  <input type="hidden" name="version" value=state.version ?? product.version />
  /* Visible form fields */
</form>

Official Statements and Architectural Philosophy

Industry standards and database architects emphasize that concurrency control must match the interaction model of the application.

"Database transactions are designed to maintain ACID properties within the boundary of a single execution thread or request lifecycle. Expecting a transaction to span human cognitive think-time across disconnected HTTP requests is a fundamental architectural misunderstanding."
— Enterprise Data Systems Architecture Guidelines

When designing user interfaces that handle concurrent edits, engineering teams must prioritize transparency. A version conflict should never trigger automatic, silent retries. Unseen retry loops that automatically fetch the latest version and resubmit modifications are, for all practical purposes, last-write-wins implementations disguised with unnecessary network round-trips.

Instead, conflict resolution should be an explicit user-facing choice. When a conflict occurs, the system should present a diff view comparing what the user attempted to save against what was concurrently committed to the database. Providing an "Overwrite Anyway" button transforms a silent data loss hazard into a conscious administrative decision.


Future Outlook

As distributed web applications become increasingly collaborative, the spectrum of concurrency management will continue to evolve. While traditional relational databases with Optimistic Concurrency Control remain the gold standard for standard CRUD applications and administrative panels, modern paradigms are shifting toward real-time collaborative editing.

For applications where multiple users edit identical documents simultaneously and expect to see live, character-by-character updates, traditional optimistic locking is insufficient. In these environments, Conflict-Free Replicated Data Types (CRDTs)—implemented via libraries like Yjs or Automerge—are rapidly replacing locking mechanisms entirely. CRDTs mathematically guarantee that concurrent edits merge seamlessly without rejecting changes or triggering conflict dialogs.

However, for the vast majority of enterprise software, line-of-business applications, and e-commerce admin panels, simultaneous edits to the exact same record remain statistically rare. For these systems, adopting Optimistic Concurrency Control via integer version columns and HTTP ETag/If-Match headers represents the optimal balance of engineering simplicity, runtime performance, and absolute data integrity. By replacing silent data destruction with explicit, fail-loud mechanisms, development teams can build robust, trustworthy applications that safeguard user data under any network condition.

Did you find this story helpful?

Share it with your friends and colleagues on social media.

Share