#3241·inertia

[3.x] A stale optimistic response silently reverts a newer optimistic save when nothing else is pending

Author: thehahndevCreated Aug 31, 2026Updated Aug 31, 2026

Inertia adapter(s) affected

Vue 3. The mechanism is entirely in @inertiajs/core, so I would expect React and Svelte to be affected equally; I have only tested Vue.

JS package version

@inertiajs/vue3 3.7.0 (@inertiajs/core 3.7.0)

Backend stack

Laravel 12.65.0, PHP 8.5.9, inertiajs/inertia-laravel v3.3.1

Describe the problem

When two optimistic saves of different controls are in flight on one page, and the first save's response lands after the second has settled, the first response's props overwrite the second control's committed value. The control snaps back on screen with no error, while the server holds the new value. Nothing corrects the screen until the next navigation.

preserveOptimisticProps() exists to prevent exactly this, but it does not run in this ordering, because the response being processed disqualifies its own request from the "is anything pending" check.

Expected: a control the user switched stays switched. A response that predates another optimistic write should not roll that write back on screen.

Actual: the second control reverts. No error is raised, and the UI disagrees with the server until a navigation refreshes the page.

Steps to reproduce

One page, two independent controls. Each writes optimistically to its own endpoint, and each endpoint answers with a redirect back, so every response carries the whole page's props read at the moment its follow-up GET ran.

  1. Save A is sent. The server commits A's write and builds A's response props.
  2. A's reply is held on the wire.
  3. Save B is sent and runs to completion. B's response is processed. B's new value is on screen and committed.
  4. A's reply is released and processed.

At step 4, A is the only optimistic request left, and A's props predate B's write. B's control reverts.

Only one condition matters: nothing else unprocessed at the moment A lands. I confirmed this with a control run. Holding B's reply as well, so something else is still pending when A is processed, makes the shield work and the bug disappear.

I have this as a Playwright spec that reproduces deterministically, 3 of 3 with --repeat-each=3, holding one real server reply rather than a synthetic one. I am happy to port it to a minimal reproduction app if that would help.

Mechanism

Three pieces in 3.7.0 combine.

Response.process() marks itself processed before it reaches setPage():

javascript
this.requestParams.runCallbacks();
this.processed = true;
// ... later in the same flow: setPage() -> preserveOptimisticProps()

Request.isPendingOptimistic() asks the response whether it has been processed:

javascript
isPendingOptimistic() {
  return this.isOptimistic() && (!this.response || !this.response.isProcessed());
}

preserveOptimisticProps() gives up when nothing is pending:

javascript
preserveOptimisticProps(pageResponse) {
  if (!router.hasPendingOptimistic()) {
    return;
  }
  // ... baseline shielding
}

By the time A reaches preserveOptimisticProps(), A has already set its own processed = true. hasPendingOptimistic() scans the request stream, finds only A, and A no longer counts itself. The guard returns and A's props land raw.

The check reads as "is another optimistic request still in flight", but as written it also counts the response doing the asking, which can never be pending by that point.

One thing that narrows a fix

isProcessed() has exactly one consumer in the bundle, isPendingOptimistic(). The flag looks like it exists only for this check, so changing when it is set, or excluding the current response from the scan, appears contained rather than far-reaching.

Why per-prop shielding is not enough

Worth flagging, since it is the first fix that comes to mind. In my case both controls live on the same prop, and Page.setBaseline() records a baseline only the first time a key changes:

javascript
setBaseline(key, value) {
  if (!(key in this.optimisticBaseline)) {
    this.optimisticBaseline[key] = value;
  }
}

So keying the shield by prop cannot separate the two writes. A fix needs to be ordering- or version-aware.

Related

#2958 introduced the baseline-and-replay mechanism and #2950 is the rollback bug it fixed. Both concern rollback when a request fails with others pending. This is the success path with nothing else pending, which I believe that work did not cover. Flagging it so this is not mistaken for a duplicate.

I first hit this on 3.0.2 and upgraded to 3.7.0 specifically to check. It reproduces identically on both, and the four functions above are unchanged between them.