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 (4)
The revision gate covers stale rendering; the final paragraph correctly draws the boundary at server-side effects. I would make that boundary visible in the example with a quote ID carried from the result to the purchase and an idempotency key on the mutation. Then the tests can assert two separate properties: stale UI never renders, and a repeated purchase request cannot create two side effects. That separation keeps the client revision counter from being mistaken for authorization or deduplication. The eleven tests exercise the first class well. Is a server mutation example planned as a follow-up?
Thanks, Mike—that’s a useful direction for a follow-up. I kept this example focused on which request may update the screen, so the client-side rule stayed easy to follow.
Your quote-to-purchase example would make the next boundary concrete: the server validates the quote’s ownership, scope, and expiry, while idempotency handles retries without duplicating the purchase. Neither guarantee comes from the revision counter.
I’ll cover this in a separate post soon, with its own runnable example and tests for retries, concurrent requests, and invalid or expired quotes. It deserves that space rather than being squeezed into this one. Appreciate the thoughtful suggestion!
Really nice write-up. What I like is that you give the bug a name. "The response is correct for the request, the screen is wrong for the current input" is exactly the thing that's hard to explain to someone who has never been bitten by it.
Two details stood out for me:
The A → B → A case. Comparing the input string feels like enough until you hit that one, and then you understand why a revision counter beats it.
Guarding the finally block too. Almost everyone guards the success path, some guard the error path, and hardly anyone notices the old request turning off the new request's loading state.
It's also the same idea as the let ignore = false cleanup pattern in React effects, but written without any framework, so it's much easier to see what's really happening. Bookmarked.
Thanks, Pierre—your React comparison is a great connection. The framework-free version makes the underlying question easier to see: does this request still own the right to update the screen?
A → B → A is particularly sneaky because the input matches again, but the original request belongs to an earlier interaction. And you nailed the
finallyproblem: an old request can leave the displayed result alone and still interfere by clearing the new request’s loading state.Appreciate you taking the time to explain what stood out to you. Glad it earned a bookmark!