This article is published in English.
Cancelling Superseded Work in React: AbortController for Track Switching
Learn why skipped tracks and stale queries keep writing to your UI, how AbortController cancels the real request, and how it complements debounce and throttle in React.
When a user changes their mind faster than the network responds, any request you already started keeps running and will happily write its result to the screen. In a search box that means results for a query the user abandoned; in a media player it means a flash of the song they just skipped. Waiting or rate-limiting does not solve this, because the work is already in flight. This article shows how AbortController cancels that work for real, how to wire it into a React effect, and how it fits alongside debounce and throttle rather than replacing them.
The race: responses arrive in the wrong order
Take a search input. The user types "ni", and a request goes out. They type "ke", and a second request follows for "nike". If the server is slower for the first, shorter query, its response lands after the second one and overwrites the correct results with stale ones.
An audio player hits the same pattern with higher stakes. Tapping a track starts loading it. Tapping a different track before the first is ready starts a second load, and now both compete. For a moment the listener might hear the skipped track, the progress bar might show the wrong duration, or two tracks might both try to take control of the player.
Debouncing would not help here. Debounce delays the start of work until input settles; it does nothing about a request that is already running. What is missing is cancellation: telling work that has begun to stop.
What goes wrong without cancellation
fetch, streams, media loading and event listeners all represent work that continues on its own once started. Without a way to stop it, you tend to see three kinds of damage:
- Stale data wins. An older response resolves after a newer one and replaces what is on screen, or in a player, starts playing.
- Wasted resources. Bandwidth, battery, server CPU and CDN requests are all spent on content nobody will see or hear.
- A haunted UI. Spinners that never stop, a waveform belonging to the previous song, a title that changes twice in quick succession.
The classic workaround is a guard such as if (requestId !== latestId) return inside each callback, or quietly ignoring the result in a .then(). It works only if every callback remembers the check, and the request still completes and downloads its bytes. AbortController goes further by cancelling the operation itself rather than just your interest in its result.
The basic AbortController pattern
A controller exposes a signal. You pass that signal to any API that accepts one, and calling abort() on the controller tells every holder of the signal to stop:
const controller = new AbortController();
fetch("/api/tracks/123", { signal: controller.signal })
.then((res) => res.json())
.then((track) => loadIntoPlayer(track))
.catch((err) => {
if (err.name === "AbortError") {
// expected. they picked a different song.
return;
}
throw err;
});
// they skipped, or left the page, or closed the player
controller.abort();
Three details deserve attention. First, the signal is passed in the fetch options, which is how fetch learns it can be cancelled. Second, aborting makes the promise reject with an error named AbortError, even if the headers already arrived and res.json() is still reading the body. Third, the catch treats that error as a normal, expected exit and rethrows everything else, so real failures are not swallowed.
The whole technique rests on a simple lifecycle: one controller per unit of intent. When the user picks a new track, abort the old controller and create a fresh one for the new load. A controller cannot be reset after it aborts, so reusing one across requests would cancel future work immediately.
If you call abort(reason) with a custom reason, fetch rejects with that reason instead of the default AbortError. In that case, checking controller.signal.aborted is a more reliable way to tell a deliberate cancellation from a genuine failure.
Tying cancellation to a React effect
In React, the natural home for the abort call is the effect cleanup. When the value that drives a request comes from props or state, the request should live exactly as long as that value does:
useEffect(() => {
const controller = new AbortController();
fetch(`/api/tracks/${trackId}`, { signal: controller.signal })
.then((res) => res.json())
.then(setTrack)
.catch((err) => {
if (err.name === "AbortError") return;
setError(err);
});
return () => controller.abort();
}, [trackId]);
When trackId changes, React runs the cleanup from the previous render before starting the next effect, so the in-flight request for the old track is aborted and only then does the new one begin. When the player unmounts, the same cleanup runs, which means a late response can never call setTrack on a component that no longer exists.
In development with Strict Mode, React mounts, cleans up and re-runs effects once on purpose. With this pattern you will see one cancelled request in the network panel; that is the cleanup doing its job, and the AbortError guard keeps it from showing up as an error.
The same signal can govern more than fetch. Streams accept it, libraries that wrap XHR often do, and addEventListener takes a signal option that removes the listener when the signal aborts. One caveat for players: a plain <audio> element does not take a signal. To stop it buffering a skipped file, clear or replace its src in the cleanup as well.
Debounce, throttle and abort solve different problems
These three tools tend to show up together around search inputs and player controls, so they are easy to confuse. Each acts at a different moment:
- Debounce waits until the user pauses, then performs the action once. Typing "n-i-k-e" quickly might produce a single request after the last keystroke.
- Throttle allows the action at most once per time window. It suits scroll, resize and repeated "skip forward" presses: the work still runs, just not on every event.
- Abort stops work that has already started.
Put differently, debounce and throttle decide when new work is allowed to start, and abort decides whether started work may continue.
A search box that feels responsive typically uses two of them. Debounce keeps you from firing a request per keystroke, and abort ensures a request that is already out cannot come back later and overwrite newer results. The dedicated article on race conditions that debouncing cannot solve in search UIs works through that case in depth.
A player follows the same split. You might throttle the "next" button so a frantic user cannot trigger twenty loads in 200 ms, but throttling does not cancel anything; it only spaces out new work. You still need to abort the load that already began.
Each alone leaves a gap: debounce still lets a slow early request land late, and abort alone still floods the server.
Walking through a playlist race
Consider what happens in an audio player when someone clicks through a playlist faster than the network can keep up.
The user taps track A. The app requests metadata, perhaps a signed URL for the file, artwork and a waveform, and the audio starts buffering. Before any of that finishes, they tap track B, then track C.
Without cancellation, everything started for A keeps arriving:
- A's metadata lands and sets the title.
- A's audio attaches to the element, producing a brief burst of the wrong song.
- The displayed duration jumps from A's length to B's to C's.
- On mobile, two complete files are downloaded that nobody will ever play.
- Analytics may log a play for A because its request succeeded, even though it was never heard.
- If the user navigates away mid-load, state updates still target an unmounted player.
With cancellation, tapping B aborts everything issued on behalf of A. The only responses permitted to touch the audio element, the title and the waveform belong to whichever track is selected at that moment. Skip again and B's work is cancelled while C's proceeds. Close the player, the cleanup runs, and nothing is left writing to a component that is gone.
Share one controller across every request for a selection, and a single abort() tears down the whole group.
The net effect is that the UI only ever reflects the user's current intent.
The same pattern in larger applications
Large applications apply this idea everywhere:
- Typeahead and filters. A new query cancels the previous request, which is the playlist race with search results instead of songs.
- Client-side routing. When a user leaves a page before its data returns, frameworks and data libraries such as Next.js, Remix, TanStack Query and SWR can abort the pending work on navigation; underneath, it is still an abort signal. Check each library's documentation for exactly when it cancels and whether it forwards a signal to your fetcher.
- Dashboards. Changing a date range can trigger a refetch across ten widgets. Change it again two seconds later without aborting the first wave, and the screen may mix figures from two different ranges.
- Anything with a Cancel button. Uploads, exports and "stop generating" in a chat interface are all a call to
controller.abort()behind the button.
Key takeaways
- Debounce and throttle control when work starts; only cancellation controls whether started work finishes.
- Create one
AbortControllerper unit of intent, pass itssignaleverywhere that work goes, and replace it rather than reusing it. - Treat
AbortErroras a normal exit and rethrow other errors. - In React, abort in the effect cleanup so changing inputs or unmounting cancels pending requests automatically.
- Remember what a signal does not reach, such as a media element's own loading, and clean those up explicitly.
Cancellation will not make an interface smarter on its own, but it guarantees that yesterday's request cannot argue with today's. Once you have seen that argument play out on screen or through a speaker, putting a signal on every request that is allowed to update the UI becomes a habit worth keeping.