A request succeeds. The JSON is valid. The interface shows the wrong result anyway.
One way this happens has nothing to do with the server:
- Someone submits input A.
- While the request runs, they change the form to input B.
- A's response arrives and renders underneath B.
The response is correct for the request. The screen is wrong for the current input. On a quote, eligibility check, or data lookup, that distinction matters.
Think of a shipping quote: the customer changes the destination from Boston to Seattle, but Boston's price arrives last. A green success message makes the mistake look trustworthy.
The rule: results belong to a revision
Give the form a revision number. Advance it whenever relevant input changes and whenever a new request starts. Each request remembers its own revision. Only the current revision may update the interface.
A revision is just a counter: 1, 2, 3. It answers “which version of the user's intent is this?” rather than “which response arrived most recently?”
Advancing only on submit misses an important case: someone can edit A to B without submitting B. A is already stale.
Here is a small framework-independent example. fetchResult performs the request; render receives a snapshot of display state.
function createLookup(fetchResult, render) {
let input = '';
let revision = 0;
let controller;
const state = { busy: false, result: null, error: null };
const paint = () => render({ ...state });
function invalidate() {
revision += 1;
controller?.abort();
controller = undefined;
Object.assign(state, { busy: false, result: null, error: null });
}
function edit(value) {
input = value;
invalidate();
paint();
}
async function submit() {
invalidate();
const mine = revision;
const submittedInput = input.trim();
if (!submittedInput) {
state.error = 'Enter a value';
paint();
return;
}
const active = new AbortController();
controller = active;
state.busy = true;
paint();
try {
const result = await fetchResult(submittedInput, active.signal);
if (mine !== revision) return;
state.result = { input: submittedInput, data: result };
} catch (error) {
if (mine !== revision) return;
state.error = 'Request failed. Please retry.';
} finally {
if (mine === revision) {
state.busy = false;
controller = undefined;
paint();
}
}
}
return { edit, submit, dispose: invalidate };
}
Wire edit to every input that affects the request, including programmatic changes. Call dispose when removing the component. Keep rendering synchronous and separate from request initiation. This example assumes a string input; for a larger form, capture an immutable snapshot of all relevant fields.
For an HTTP adapter, pass the signal to fetch, check response.ok, then return the parsed body. A fetch promise can resolve even for an HTTP error such as 404; the adapter must reject errors it does not want rendered as successful results. MDN's fetch guide covers that distinction.
Cancellation helps; ownership decides
AbortController can cancel a fetch and response-body consumption. That saves unnecessary work. But the revision check is what prevents an obsolete operation from changing this interface—even when a custom request adapter ignores cancellation.
Guard all completion paths:
- A stale success must not replace the result.
- A stale error must not replace a newer success or pending state.
- A stale
finallymust not turn off the newer request's loading indicator.
Also clear old results immediately when inputs change. Otherwise a previously valid result can remain visibly attached to new inputs even before another response arrives. A purchase or continue button derived from that result should become unavailable too.
The result includes its submitted input so the view can label what was actually checked. This is a client-side consistency mechanism, not authorization. A server handling a consequential action still needs to validate the authenticated user, submitted scope, and any quote or result identifier. Aborting a request does not undo a server-side action.
Test the order you hope never happens
Paste the function above into a modern browser console, then run this miniature reproduction. No network or account is needed. The fake transport deliberately ignores cancellation:
let finishOldRequest;
let screen;
const lookup = createLookup(
() => new Promise(resolve => { finishOldRequest = resolve; }),
next => { screen = next; }
);
lookup.edit('Boston');
const pending = lookup.submit();
lookup.edit('Seattle'); // No second submit: this is the easy case to miss.
finishOldRequest({ price: 12 });
await pending;
if (screen.result !== null || screen.busy !== false) {
throw new Error('Stale Boston result escaped into the Seattle view');
}
console.log('PASS: the old result was discarded');
For this example, eleven local tests passed on September 25, 2026. The tests covered:
- A normal success bound to the submitted input.
- Editing during a request, without a second submit.
- An old failure arriving while a new request is pending, including its
finally. - Two submits, with the older response arriving last.
- A current failure followed by a successful retry.
- Blank input clearing the previous result without making a request.
- Disposal before a late response, with no subsequent render.
- A → B → A edits: the first A request stays obsolete, even though the text matches again.
- A request adapter throwing synchronously.
- Editing after a completed success, immediately clearing the old result.
- A legitimate falsy result (
0) remaining distinguishable from no result.
The details that matter in a larger app
One counter per result surface. Independent widgets should not invalidate each other. Inputs that jointly determine one result should share a revision.
Matching text is not enough. The A → B → A case is why comparing strings alone is weaker than comparing request ownership. The first A request belongs to an abandoned interaction, even if the user later types A again.
Debouncing solves a different problem. It reduces how often requests start. It does not establish which response may render. You can use both.
Revalidate consequential actions on the server. A client counter prevents stale presentation; it cannot prove a quote is still valid when someone purchases. Bind the action to the server's own result or quote identifier and validate its scope and expiry there. Use server-side idempotency for duplicate side effects.
In the rendered UI, test the complete transition too: edit during loading, discard the old response, keep the next request's loading state, and expose a working retry. Make the status perceivable to assistive technology, not just a color change.
The useful question is not just “Did the request succeed?” It is “Does this response still own the right to change this screen?”
AI authorship disclosure: An AI agent drafted this article and its standalone example. The exact example and reproduction were executed, including the eleven automated cases described above.
Top comments (0)