How do you prevent stale optimistic updates when async responses return out of order?

Yo folks, I’m wiring up a little React-ish UI for a pixel-art tile editor and I’m trying to do optimistic saves while people paint fast. The failure mode is ugly: a slower response can overwrite newer state, so the UI “snaps back” and feels broken.

let version = 0;
let state = { tiles: [] };

async function saveTiles(nextTiles) {
  const v = ++version;
  state = { ...state, tiles: nextTiles }; // optimistic

  const res = await fetch('/api/tiles', {
    method: 'POST',
    headers: { 'content-type': 'application/json' },
    body: JSON.stringify({ tiles: nextTiles, v })
  }).then(r => r.json());

  state = { ...state, tiles: res.tiles }; // can apply stale server result
}

What’s the cleanest pattern to reconcile optimistic UI state with out-of-order responses without leaking memory or making every update feel laggy?

Yoshiii

That state = res. tiles line is the part that gets me—don’t ever apply a response unless it’s still the newest request you’ve sent. Keep a latestSentVersion (or latestAckedVersion) and gate the response:```
let version = 0;
let latestSent = 0;

async function saveTiles(nextTiles) {
const v = ++version;
latestSent = v;

state = { …state, tiles: nextTiles }; // optimistic

const res = await fetch(‘/api/tiles’, { /* … */ }).then(r => r.json());

if (v !== latestSent) return; // stale response, ignore

state = { …state, tiles: res.tiles };
}


 It doesn’t leak memory because you’re not storing old promises, and it avoids the snap-back because older responses just get dropped on the floor. The one risk to watch is server-side “fixups” (like normalization); if the server can change tiles, you’ll want it to echo back the `v` and only treat the newest `v` as authoritative.

I’ve used basically this “version gate” pattern and it stops the snap-back instantly, but you do have to decide what to do with server fixups you’re ignoring. In one project we solved it by having the server return the version plus a minimal patch (or canonicalized IDs) so the newest request can still absorb normalization without letting an older response overwrite newer UI state.

@BobaMilk, The “server fixups” part is where this gets spicy: if you’re dropping stale responses, you probably want to cancel the stale requests too, not just ignore them. An AbortController per save (abort the previous one when a new paint happens) cuts load and reduces the chance your backend applies a bunch of writes you never intend to show. Something like:

let inFlight = null;

async function saveTiles(payload) {
  // cancel the previous save if it's still running
  inFlight?.abort();
  inFlight = new AbortController();

  const res = await fetch("/api/tiles", {
    method: "POST",
    headers: { "content-type": "application/json" },
    body: JSON.stringify(payload),
    signal: inFlight.signal,
  });

  return res.json();
}

Question though: is your /api/tiles endpoint doing “last write wins” on the server, or can an older request actually overwrite newer data server-side? Because gating in the client won’t save you from that.

AbortController only stops the client side request. If the server already got the write, the abort doesn’t magically roll it back. That’s the part people miss.

For the server, I’d do a simple version check: client sends a monotonically increasing v, server keeps the latest one, and anything older gets ignored or gets a 409. That way an out-of-order response can be stale without actually clobbering newer data.

@Yoshiii, does your backend already track something like that per record, or is it still just taking whichever request shows up last?

lol yeah I’m currently running the shameful “last write wins” backend, so aborting on the client just prevents the UI snap-back while the server happily clobbers the newer state anyway.

On the version check, I’ve had better luck persisting a clientRevision on the record and returning a 409 on older writes, because it forces the client to treat it as “nope, don’t apply” instead of accidentally believing some stale success response. I like your clientRevision name too — v always turns into “wait which v is this” six weeks later.

Look — client aborts are UX band-aids, not consistency. The 409-on-stale-writes pattern is solid because it turns “out of order” into an explicit conflict path, and you can log/alert on it when some client goes feral and starts replaying old mutations.