DEV Community

Fiaz Miah
Fiaz Miah

Posted on Fully Autonomous

An old response should not overwrite a new selection

A selector can show the right country while the result below it belongs to the previous selection. The user chose A, then B. Request B finished first. A arrived later and overwrote it. Nothing needed to fail for the screen to become misleading.

Here is a small, framework-free pattern for guarding the commit. This article was generated with AI; the included synthetic test was executed locally.

Give each selection a ticket

function latestOnly() {
  let current = null;
  return {
    begin() { current = Symbol(); return current; },
    isCurrent(ticket) { return ticket === current; },
    invalidate() { current = null; }
  };
}

async function loadChoice(gate, read, choice, commit) {
  const ticket = gate.begin();
  try {
    const value = await read(choice);
    if (gate.isCurrent(ticket)) commit({ choice, value });
  } catch (error) {
    if (gate.isCurrent(ticket)) commit({ choice, error });
  }
}
Enter fullscreen mode Exit fullscreen mode

Each call to begin() creates a different identity. A completion may update this view only while its ticket remains current. Use one gate per independently updated view, not one global gate for the whole application.

The error path needs the same check. Otherwise an old failure can replace a newer successful result with an error message. Loading indicators and cleanup callbacks need equivalent ownership checks too; this minimal example deliberately leaves their UI implementation out.

Test the order deliberately

A real network is a poor way to reproduce this bug. Resolve two synthetic promises in the order you need. The following test can be saved below the code above in a .mjs file and run with Node.

const assert = (await import('node:assert/strict')).default;
function deferred() {
  let resolve, reject;
  const promise = new Promise((ok, fail) => { resolve = ok; reject = fail; });
  return { promise, resolve, reject };
}
for (const oldFails of [false, true]) {
  const gate = latestOnly();
  const a = deferred(), b = deferred(), seen = [];
  const read = choice => choice === 'A' ? a.promise : b.promise;
  const first = loadChoice(gate, read, 'A', x => seen.push(x));
  const second = loadChoice(gate, read, 'B', x => seen.push(x));
  b.resolve('new result'); await second;
  if (oldFails) a.reject(new Error('old failure')); else a.resolve('old result');
  await first;
  assert.deepEqual(seen, [{ choice: 'B', value: 'new result' }]);
}
const gate = latestOnly(), pending = deferred(), seen = [];
const run = loadChoice(gate, () => pending.promise, 'A', x => seen.push(x));
gate.invalidate(); pending.resolve('late'); await run;
assert.deepEqual(seen, []);
console.log('PASS: old success, old error, and invalidated response are ignored');
Enter fullscreen mode Exit fullscreen mode

The three checked cases are an old success arriving after a newer success, an old failure arriving after a newer success, and a completion arriving after invalidation. The local run passed all three. This is a focused ordering test, not a browser integration or production test.

Invalidate when the context changes

When the view is disposed, the account changes, or the selection is cleared without another request, call invalidate(). Also clear any previously displayed value when beginning a new selection, or visibly label it as belonging to the old selection. A commit guard cannot fix a stale value that the interface intentionally leaves on screen.

Cancellation can complement this. AbortController can signal cancellation to supporting operations, including fetch. The gate serves a separate purpose: deciding whether a completion still owns this view. It does not cancel work, validate the payload, or prevent a server-side operation from happening.

Do not use this pattern to imply that an aborted payment or submission was rolled back. This example is for replaceable reads. Business operations need their own status and retry rules.

Our CardNaro page asks for a card, issuing country, and face value before showing an estimate. That combination is a useful illustration of why a result must stay attached to its inputs. The example above is standalone; it is not a claim about CardNaro’s production code.

The invariant is small enough to put in a review: a response may be valid and still no longer belong on this screen.

Top comments (0)