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.