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.
Requests Can Complete Out of Order
User scrolls quickly Client 62% ---------------------> request A70% -----------> request B Server request Bprogress = 70 | vstored 70Neither 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
$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
Allowed: 12 -> 24 -> 48 -> 73 -> 100 Should not happen: 12 -> 48 -> 73 -> 55 Rule: new_progress >= stored_progressMonotonicity 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
$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
Database: progress = 50 Request A reads 50incoming = 80max = 80 Request B reads 50incoming = 70max = 70 Request A writes 80Both 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
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
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()
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
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
$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
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.



