0% прочитано

When State Must Never Go Backwards: Why Last Write Wins Is Wrong for Progress

During fast scrolling, a later HTTP request may contain a lower progress value than one already persisted. If progress is monotonic by definition, the last request to arrive should not automatically win.

7 жовтня 2026 р. 8 хв читанняLaravel

Reading progress looks simple at first.

The user scrolls to 62%, the client sends 62, and the server stores 62.

Then the user reaches 70%, and the server stores 70.

The problem appears when requests do not arrive in the order in which they were created.

During fast scrolling, the request carrying 70 may finish first while an older request carrying 62 arrives slightly later.

If the server simply stores the last value it receives, progress moves backwards.

If a value is only allowed to increase by domain rules, HTTP arrival order should not determine its final state.

Requests Can Complete Out of Order

An older request wins only because it arrived later
User scrolls quickly  Client 62%  --------------------->  request A70%  ----------->           request B  Server request Bprogress = 70        |        vstored 70

Neither request is incorrect on its own.

Both values accurately represented the interface state when they were created.

The bug is in the persistence rule: the server behaves as if request arrival order were the same as business-state order.

That assumption is unreliable in a distributed system.

A Simple Update Implements Last Write Wins

PHP - The latest request replaces the previous value
$progress = $request->integer(    'progress_percent',); $reading->update([    'progress_percent' =>        $progress,]);

For many fields, this behavior is completely reasonable.

If a user changes a name or preference, the new value can legitimately replace the old one.

Progress has different semantics.

If the user has already reached 70%, a later update to 62% does not describe newer progress. It moves state backwards because of request delivery order.

Progress Is Monotonic State in This Flow

The value can only increase
Allowed: 12 -> 24 -> 48 -> 73 -> 100  Should not happen: 12 -> 48 -> 73 -> 55  Rule: new_progress >= stored_progress

Monotonicity is part of the meaning of this field.

A new client message does not mean "replace progress with this value".

It means: "the user has reached at least this point".

The persistence rule is therefore different:

keep the greater of the stored value and the incoming value.

The First Correct Rule Is to Preserve the Maximum

PHP - Keep the maximum value
$incomingProgress =    $request->integer(        'progress_percent',    ); $nextProgress = max(    $reading->progress_percent,    $incomingProgress,); $reading->update([    'progress_percent' =>        $nextProgress,]);

This code already represents the business rule more accurately.

A request carrying a lower value cannot reduce progress.

But if multiple requests execute concurrently, an in-memory max() may still be insufficient.

max() Alone Does Not Prevent a Concurrent Read-Write Race

Both requests calculated max from stale state
Database: progress = 50  Request A reads 50incoming = 80max = 80  Request B reads 50incoming = 70max = 70  Request A writes 80

Both requests used max() correctly.

But both read the old value of 50 before either persisted the newer value.

The problem is no longer the formula itself. The read and write are not protected as one operation.

One Option Is to Re-read the Value Under a Lock

PHP - Current state is read before the write
DB::transaction(    function () use (        $readingId,        $incomingProgress,    ): void {        $reading = ReadingProgress::query()            ->whereKey(                $readingId,            )            ->lockForUpdate()            ->firstOrFail();         $reading->progress_percent = max(            $reading->progress_percent,            $incomingProgress,        );

A concurrent request can no longer read the same row for the critical update at the same time.

It waits for the first transaction, then observes the newer value and calculates max() from current state.

For example, after 80 has been stored, a request carrying 70 simply preserves 80.

For a Simple Numeric Rule, the Database Can Update Atomically

PHP - Comparison and write happen in one SQL operation
DB::update(    <<<'SQL'        UPDATE reading_progress        SET progress_percent = GREATEST(            progress_percent,            ?        )        WHERE id = ?    SQL,    [        $incomingProgress,        $readingId,    ],);

When the rule really is just a numeric maximum, the database can perform the comparison and write atomically.

There is no separate application read between which another request can modify the row.

This works especially well for a simple monotonic field.

If the decision depends on several fields or more complex state, an explicit transaction and revalidation may be easier to reason about.

Not Every Field in the Same Record Uses max()

Merge semantics belong to each field separately
progress_percent-> preserve maximum  completed_at-> once completed,   preserve completion  last_read_at-> keep newer timestamp  current_position-> depends on field semantics 

It is important not to turn max() into a universal rule for the entire record.

Percentage progress may be monotonic while another field has completely different semantics.

For concurrent updates, I therefore define not only "which request wins" but how each individual field should merge.

Completion Should Not Be Accidentally Reversed Either

An older request must not undo completion
Stored: progress = 100completed = yes  An older request arrives later: progress = 87  Wrong: progress = 87completed = no 

If completion follows from achieved progress, it should respect the same monotonic property.

After completion, an older network request should not move the content back into an incomplete state.

At that point the whole invariant matters, not only one numeric field.

If Progress Really Needs to Reset, Make It an Explicit Operation

PHP - Normal progress updates and reset have different semantics
$progressService->record(    readingId: $readingId,    progress: 82,); $progressService->reset(    readingId: $readingId,);

Monotonicity does not mean the value can never become smaller under any circumstances.

A product may support an explicit "start over" action.

But that represents a different user intent.

A delayed ordinary request should not accidentally perform a reset.

If decreasing the state is allowed at all, it is better represented by a separate explicit operation.

The Client Can Reduce Requests, but the Server Still Enforces the Rule

The client can debounce, throttle, or avoid sending every single percentage change as a separate request.

That is useful for reducing load.

But it is not a correctness guarantee.

The network may still delay requests, the user may open several tabs, and multiple devices may update the same state concurrently.

The server still has to enforce the rule that progress does not decrease.

This Pattern Is Not Limited to Reading Progress

The same model appears in other places where state has a natural direction.

For example:

  • the highest stage ever reached;
  • the greatest processed sequence number;
  • the newest confirmed version;
  • a once-completed operation that must remain completed;

In each case, it helps to define the mathematical state rule first and only then choose the database update strategy.

What I Verify in Tests

Core scenarios
1. Stored 40, incoming 60   -> store 60  2. Stored 60, incoming 40   -> keep 60  3. Stored 60, incoming 60   -> keep 60  4. Concurrent 80 and 70   -> final value remains 80  

What I Check When State Must Not Move Backwards

My short checklist:

  • Can this value legitimately decrease?
  • Does a lower value represent new intent or merely an older message?
  • Can requests arrive out of order?
  • Is a simple max() enough, or is there a concurrent read-write race?
  • Can comparison and persistence happen atomically in the database?
  • Does current state need to be re-read under lockForUpdate()?
  • Do other fields in the record need different merge rules?
  • Can an older request accidentally undo a completed state?
  • If reset is required, is it modeled as a separate operation?

The Last Request Does Not Always Deserve to Win

In this bug, the server did not lose requests or store invalid numbers.

The problem was subtler: valid values were applied using the wrong state rule.

For monotonic progress, a later HTTP request does not necessarily represent newer state.

I therefore do not let network delivery order decide how far the user has already progressed.

I preserve the highest achieved value and protect that rule against concurrent updates.

Last write wins is a persistence strategy. It is correct only when it matches the semantics of the state itself.

If state is allowed to move only forward, an older request should not move it backwards merely because it arrived last.