跪拜 Guibai
← Back to the summary

Six Race-Condition Fixes, One Real Winner: AbortController

Type "abc" into the search box, the list flickers, but what shows up is the result for "a". Refresh and it's fine; type again and it reproduces. That is a request race condition. Every frontend developer who writes search, filtering, or pagination has seen it, and everyone has their own defense: add loading, add debounce, put a sequence number on requests. Dropdown filters, tab switches, infinite scroll, it's all the same mine wearing a different costume.

I put all 6 defenses on the same test bench, ran dozens of rounds at two typing rhythms, with scoring criteria fixed before the runs. Verdict first:

Approach Fast typing Normal typing speed Server actually computed (fast) Verdict
Nothing added 100% shows old data 47% shows old data 3/3 Broken
loading flag guard 100% wrong, new input swallowed 43% shows old data 1/3 (wrongs worse) Broken
300ms debounce All correct (costs 300ms) 50% shows old data 3/3 Broken
Request sequence guard All correct All correct 3/3 wasted Usable
Latest-value comparison All correct All correct 3/3 wasted Usable
AbortController All correct All correct 1/3, truly cancelled Fully capable

The two most popular fallbacks, loading flag and debounce, both land in the "broken" tier. Going through them one by one, worst first.

How to read the verdict table: the first two columns are the proportion of rounds out of 30 where wrong data ended up displayed; the third column is the number of requests the server actually finished computing across the 30 fast-typing rounds (3 requests were sent in total), which measures waste, not correctness.

How the test bench was built

Scoring criteria fixed before the runs: final UI correct AND no wasted request = fully capable; correct but wasted computation = usable; any typing rhythm that displays old data = broken. No defining criteria after the fact.

The server was a local search endpoint with one rule: the shorter the word, the more hits, the slower the computation ("a" takes 600ms, "abc" 150ms, with random jitter). This is not a contrivance. Real search backends behave like this: short words match more documents and heavier aggregations, so they run slower than long words. Caching amplifies it further: long words often hit the cache and return instantly, while short words always break through to the compute layer.

Input stream a → ab → abc, two rhythms: fast typing at 60ms intervals (that autocomplete feel), and normal typing at 400ms intervals. Why both? Fast typing tests whether the defense holds up; normal typing tests whether it quietly leaks. Some defenses ace fast typing but fail at normal speed, and debounce is that one. Testing only one rhythm is a wasted test. Node ran 30 rounds, then a real Chrome ran 10 rounds for cross-validation; the two sides reached exactly the same conclusions. The "normal typing speed" column in the verdict table is debounce's death column.

How old data legally gets on screen

The bare group was wrong in all 30 fast rounds. Spread the timeline out and the mechanism is plain:

t=0     send a   (backend 600ms)
t=60    send ab  (backend 350ms)
t=120   send abc (backend 150ms)
t=270   abc returns → rendered ✓
t=530   ab returns → rendered (UI already wrong at this point)
t=720   a returns → rendered (ends on the wrong result)

The return order is the exact reverse of the send order; whoever arrives last gets rendered. "a" is always last. The correct "abc" result stays on screen for less than half a second before two stale responses wash over it in turn. The user sees a spinner, two flickers, then a stale "a" result.

That spinner you wait for, a lot of the time it ends on garbage. The normal-typing group also hit a 47% error rate, which means "it works without fixing" is an illusion: a normal half-day of use will run into it once.

Someone might ask: at 400ms intervals, "a" came back 100ms before "ab" was even sent, how does it go out of order? The answer is backend variance. I put ±600ms of random jitter on the endpoint. Real backends have this temperament: cache hit 50ms, cache miss 800ms, the same endpoint can vary in latency by a factor of ten. At normal typing speed, "a" happens to draw a long duration and gets overtaken by the later "ab". Out-of-order does not require you to type fast. One random backend fluctuation is enough.

The loading flag does not prevent races, it swallows input

The most widely circulated defense looks like this:

let loading = false;
async function search(q) {
  if (loading) return;      // ← the mine
  loading = true;
  const res = await fetch(`/api/search?q=${q}`);
  render(await res.json());
  loading = false;
}

What happens on fast typing: "a" is sent, loading flips to true, the "ab" and "abc" that follow are all blocked by that return, a's result comes back and renders, loading releases, done.

Your abc request was never sent, at any point.

30/30 wrong, and nothing looks abnormal in the UI: the list has data, no error, no spinner, the user thinks the search finished. At normal speed the flag does release, but the window after release is still naked racing, 43% wrong.

This is the only one of the six I'd suggest you grep your codebase for tonight: search for if (loading) return, and if it sits inside a search function, you are probably sitting on this mine.

Why is this pattern everywhere? Three reasons: the code is shortest, two lines; it looks like it saves requests, which matches intuition; and even plenty of interview teaching materials write it. But it defends against "duplicate submission", not races. The essence of a race is out-of-order responses. The flag never touches out-of-order at all, it just narrows the entrance where requests are sent. Narrow the entrance, and the inside still races as it always did.

Debounce is a rhythm gambler

Debounce is genuinely perfect on fast typing: key intervals stay under 300ms, requests collapse into one, all correct. That's where "debounce means no race" comes from.

But move the key interval to 400ms, which is how normal humans type, and debounce lets every keystroke through, each key sends its own request, and the race window is identical to naked racing: 50% old data.

Debounce only manages frequency, not correctness. It bets that you type fast, and when the bet loses, it's naked. (This is not debounce's first time taking the blame; lazy loading already pinned one on it in an earlier post.)

A fair word for it: debounce itself is not wrong. It solves the frequency problem of "don't send a request for every keystroke". What is wrong is treating it as a race defense. It never took that job. So the conclusion is not to abandon debounce, it's to stop expecting it to prevent races.

Also, being all-correct on fast typing carries a price: a 300ms delay tax. The correct result renders at 576ms. AbortController gets there in 269ms.

The two that keep the UI correct, while the server burns cycles

The sequence guard and the latest-value comparison are the "correct school": when a response arrives, verify its identity first, discard the stale ones. The sequence guard compares request sequence numbers, the latest-value comparison compares the query string, and the two approaches are equivalent. 60/60 rounds with zero UI errors.

The sequence guard looks like this, three core lines:

let seq = 0;
async function search(q) {
  const mySeq = ++seq;
  const res = await fetch(`/api/search?q=${q}`);
  if (mySeq !== seq) return;   // stale response, discard
  render(await res.json());
}

The problem sits on the other side. How many times did the server actually compute? 3/3. Every keystroke sends a full request, and two-thirds of the computed results get thrown away. Search autocomplete is a per-keystroke scenario, and this waste multiplies across your QPS. On a weak mobile network it costs more: 3 requests, 3x the traffic, 3x the battery, and the user sees one result.

Why not fully adopt it? Abort kills a request halfway down the road; the sequence guard lets the request run the whole course and then checks the ticket. The ticket is void, but the fare is still paid. Usable. But know that you're paying it.

AbortController, the only fully capable contestant

let ctrl;
async function search(q) {
  ctrl?.abort();                // previous request, dead
  ctrl = new AbortController();
  const res = await fetch(`/api/search?q=${q}`, { signal: ctrl.signal });
  render(await res.json());
}

Stale requests get cancelled mid-flight; there is no chance for them to "come back and overwrite". UI is 60/60 all correct. The server logs show the cancellation scene:

ABORT q=a    after=63ms
ABORT q=ab   after=70ms

The connection is closed early by the client, and the server stops computing. Averaged over 30 rounds, the server drops from computing 3 times to computing once; two-thirds of the compute is saved directly. From the server's point of view, a cancelled request is a socket closing early: the request headers came in, computation ran halfway, and the peer vanished. If your backend monitors endpoint latency, requests that get aborted never count into completed latency at all. This frontend move saves compute, and the backend monitoring can see it.

Two things to watch. One: abort throws an AbortError; without a catch it's red text in the console, so always catch it. Two: also abort on component unmount. The most typical React form is cleanup inside useEffect:

useEffect(() => {
  const ctrl = new AbortController();
  fetch(`/api/search?q=${q}`, { signal: ctrl.signal })
    .then((r) => r.json())
    .then(render)
    .catch((e) => { if (e.name !== 'AbortError') throw e; });
  return () => ctrl.abort();   // q changed or component unmounted, old request dies on the spot
}, [q]);

Without that cleanup line, a ghost response can fly back after you switch pages, and your setState lands on an unmounted component. The race has moved from the search box into the page lifecycle, following the same pattern.

Some people think abort has a high bar: compatibility, library support. By 2026 that worry is settled. fetch, axios, and uni.request all accept a signal, Node's fetch supports it natively, and browser support has been full green for a long time. Today it is the least effortful of the six to write.

The correct way to carry it

One sentence: debounce manages frequency, abort manages correctness, use both together.

let ctrl, timer;
function onInput(q) {
  clearTimeout(timer);
  timer = setTimeout(() => {
    ctrl?.abort();
    ctrl = new AbortController();
    fetch(`/api/search?q=${q}`, { signal: ctrl.signal })
      .then((r) => r.json())
      .then(render)
      .catch((e) => { if (e.name !== 'AbortError') throw e; });
  }, 300);
}

Debounce compresses the request frequency; abort covers every race window. For scenarios where requests can't be aborted (POST queries, GraphQL), fall back to the sequence guard, but know the server is computing wasted work. Libraries like React Query and SWR are internally doing this abort-plus-guard combo. Hand-written code can copy the same idea.

Existing codebase doesn't need a rewrite: if an old search function already has debounce around it, adding the abort trio at the request line is a change of no more than 5 lines. For total peace of mind, swap the whole block for TanStack Query's useQuery({ queryKey: [q] }), which ships race handling built in. Three lines to start, depending on how much you want to invest.

One-line cheat sheet:

Two objections, answered in advance

"I never added any race protection and nothing ever happened." Two possibilities: your requests happen to always be faster than the older ones (for example the endpoint returns strictly in order), or you haven't been watching. At a 47% error rate on normal typing, running into it is a matter of time. Scenarios that genuinely need nothing also exist: a detail page with only one request, or a backend that queues requests. But "our backend queues" is a claim to verify before believing, not something to take on trust.

"The sequence guard is enough, don't scare people." It is enough; the verdict table says "usable". But two-thirds of wasted requests per keystroke is a real cost. Choosing with knowledge of the cost and choosing without it are two different things.

"Does axios CancelToken work?" It works, but axios has officially marked it deprecated, and the official docs point to AbortController as the replacement. Don't write new code on CancelToken. Some people also think React's setState batching or concurrent features can catch out-of-order responses. Batching merges multiple setState calls into one render; it does not control which network response lands first. Those are two different things. Out-of-order happens at the network layer; no framework layer can catch it, only your request layer can.

Experiment boundaries

Three. One: the server is a local single-machine endpoint with no public-network RTT variance. The real internet only races more violently, not less. Two: the verdict numbers are Node 30 rounds plus Chrome 10 rounds averaged, and all experiment code lives in the race-lab directory, rerunnable. Three: framework behavior is out of scope for this run. React Strict Mode's double effect and React Query's internal deduplication deserve a separate write-up.

Comment below with which method your project uses, especially if you still have if (loading) return in place. Stories of stepping on flag-swallowed input are even better, tell how you tracked it down. Once the material piles up, I'll move the library-layer race post up the queue.

Comments

Top 1 of 2 from juejin.cn, machine-translated. The original thread is authoritative.

我是李工啊

The experimental design is solid. One pitfall I've stumbled into: sequence-number guarding and AbortController are best used together. AbortController handles eliminating unnecessary requests, while sequence-number/latest-value comparison catches responses that have already arrived but are stale (e.g., cases where some libraries don't support cancellation). Also, the loading-flag swallowing input problem is essentially treating "preventing duplicate submission" and "getting the latest result" as the same thing; the former fits submit-type operations, and the latter fits search-type scenarios.

kyriewen

Like