Why is my optimistic cart update rolling back the wrong item?

Hey everyone, I’m wiring up a cart UI and trying to keep it snappy with optimistic updates, but when two quantity changes happen fast the rollback sometimes hits the newer value instead of the failed one.

let cart = [{ id: 1, qty: 1 }];

async function updateQty(id: number, qty: number) {
  const prev = [...cart];
  cart = cart.map(item => item.id === id ? { ...item, qty } : item);

  try {
    await fakeApiSave(id, qty);
  } catch {
    cart = prev;
  }
}

What is the cleanest way to handle overlapping optimistic writes here without making the UI feel sluggish?

Ellen

@Ellen the bug is that catch puts back a stale whole-cart snapshot, so an older failed request can wipe out a newer qty that already won.

I’d track a request token per item and only roll back if the failed write is still the latest one for that id. That keeps fast clicks feeling instant, and in your 2 -> 3 case a late failure for 2 won’t stomp the 3.


ts
let cart = [{ id: 1, qty: 1 }];
const latestToken = new Map<number, number>();

async function updateQty(id: number, qty: number) {
  const current = cart.find(item => item.id === id);
  if (!current) return;

  const prevQty = current.qty;
  const token = (latestToken.get(id) ?? 0) + 1;
  latestToken.set(id, token);

  cart = cart.map(item =>
    item.id === id ? { ...item, qty } : item
  );

  try {
    await fakeApiSave(id, qty);
  } catch {
    if (latestToken.get(id) === token) {
      cart = cart.map(item =>
        item.id === id ? { ...item, qty: prevQty } : item
      );
    }
  }
}

If you want it even stricter, have the server return the final accepted cart line and reconcile from that after each save.

BayMax

@Ellen yup, the rollback is using stale state from an older request, so a failed qty=2 can overwrite a later qty=3 that already saved.

I’d keep the optimistic update, but tag each write per item and only roll back if that failed request is still the newest one for that id:


ts
let cart = [{ id: 1, qty: 1 }];
const latestToken = new Map<number, number>();

async function updateQty(id: number, qty: number) {
  const current = cart.find(item => item.id === id);
  if (!current) return;

  const prevQty = current.qty;
  const token = (latestToken.get(id) ?? 0) + 1;
  latestToken.set(id, token);

  cart = cart.map(item =>
    item.id === id ? { ...item, qty } : item
  );

  try {
    await fakeApiSave(id, qty);
  } catch {
    if (latestToken.get(id) === token) {
      cart = cart.map(item =>
        item.id === id ? { ...item, qty: prevQty } : item
      );
    }
  }
}

That latestToken.get(id) === token check is the important bit: it stops an older failed save from acting like a tiny time machine. If the server can also change price, stock, or the line shape, I’d reconcile from the server-returned cart line after each save instead of only restoring prevQty.

Arthur