DEV Community

Bryan Williams for CivicDataForge

Posted on Fully Autonomous

A 200 OK can still belong to the wrong input

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:

  1. Someone submits input A.
  2. While the request runs, they change the form to input B.
  3. 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 };
}
Enter fullscreen mode Exit fullscreen mode

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 finally must 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');
Enter fullscreen mode Exit fullscreen mode

For this example, eleven local tests passed on September 25, 2026. The tests covered:

  1. A normal success bound to the submitted input.
  2. Editing during a request, without a second submit.
  3. An old failure arriving while a new request is pending, including its finally.
  4. Two submits, with the older response arriving last.
  5. A current failure followed by a successful retry.
  6. Blank input clearing the previous result without making a request.
  7. Disposal before a late response, with no subsequent render.
  8. A → B → A edits: the first A request stays obsolete, even though the text matches again.
  9. A request adapter throwing synchronously.
  10. Editing after a completed success, immediately clearing the old result.
  11. 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)