Canceling an in-flight request sounds like a solved problem once you know AbortController exists. The part that trips people up isn't the cancellation itself, it's cleaning up everything that was attached to the request: event listeners, timers, and component state that keeps a reference alive after the thing it was tracking no longer matters.
Step 1: Create One Controller Per Request, Not One Per Component
A common mistake is creating a single AbortController when a component mounts and reusing it for every request that component makes. The problem: once you call .abort() on a controller, it's permanently aborted, you can't reset it and reuse it for the next request. Every new request needs its own fresh controller.
function search(query) {
const controller = new AbortController();
fetch(`/api/search?q=${query}`, { signal: controller.signal })
.then(res => res.json())
.then(renderResults)
.catch(err => {
if (err.name === "AbortError") return; // expected, not a real error
handleRealError(err);
});
return controller;
}
Store the returned controller somewhere your caller can reach it (component state, a ref, a closure variable), so the next call can abort it before creating a new one.
Step 2: Abort the Previous Request Before Starting a New One
let currentController = null;
function handleInput(query) {
if (currentController) currentController.abort();
currentController = search(query);
}
This is the core pattern: every new search input aborts whatever was previously in flight before starting fresh. It's a small amount of code, and it's the piece that actually closes the race condition where an older, slower response overwrites a newer, faster one that already rendered.
Step 3: Don't Forget to Abort on Unmount
This is the step that actually causes memory leaks if skipped. If a component makes a request and then unmounts before that request resolves, the .then() callback still fires when the response eventually arrives, and if it tries to update state on an unmounted component, most frameworks will at minimum log a warning, and at worst hold a reference that prevents the component from being garbage collected until the request finally settles.
In a React component using useEffect, the cleanup function is the natural place for this:
useEffect(() => {
const controller = new AbortController();
fetch(`/api/search?q=${query}`, { signal: controller.signal })
.then(res => res.json())
.then(setResults)
.catch(err => {
if (err.name !== "AbortError") console.error(err);
});
return () => controller.abort();
}, [query]);
Every time query changes, or the component unmounts, the cleanup function fires and aborts whatever request was still pending. This single pattern handles both the race-condition problem and the unmount memory leak with the same mechanism, which is part of why it's worth reaching for by default rather than only adding it after a leak shows up in profiling.
Step 4: Remember That Aborting Doesn't Cancel Server-Side Work
Worth knowing: calling .abort() stops the browser from processing the response and frees up the connection on the client side, but it doesn't necessarily stop the server from finishing whatever work it already started. If your search endpoint kicks off an expensive database query, aborting the client-side fetch doesn't cancel that query unless your server infrastructure specifically watches for a closed connection and cancels the underlying work in response. For a fast autocomplete endpoint this is usually a non-issue, but for anything triggering a genuinely expensive backend operation, it's worth checking whether your server stack actually respects a closed connection.
Step 5: Test the Unmount Case Specifically
The unmount leak is easy to miss in normal testing because most manual testing doesn't involve rapidly mounting and unmounting a component while requests are in flight. It's worth deliberately testing: trigger a slow request (throttle your network in devtools), then navigate away or unmount the component before it resolves, and confirm nothing errors and nothing holds a stale reference. This is exactly the scenario that's invisible in a quick manual click-through and shows up later as a vague, hard-to-reproduce memory growth issue in production.
A Common Mistake: Aborting Inside the Wrong Scope
A subtler version of this bug shows up when the controller is created inside a function that's called on every render but the abort call happens somewhere that only runs conditionally. If your abort logic lives inside an if branch that doesn't always execute, for example only aborting when a new query is non-empty, but still creating a new controller unconditionally, you end up with orphaned controllers whose requests were never actually aborted, silently piling up. The safest pattern is unconditional: always abort the previous controller if one exists, then always create a new one, with no branching in between that could skip the abort call under some condition you didn't anticipate.
Vue and Svelte Follow the Same Shape
This isn't React-specific. Vue's onUnmounted hook and Svelte's onDestroy serve the same role as React's useEffect cleanup function, a guaranteed place to abort any pending request tied to a component instance before that instance goes away. The underlying pattern, create a controller, store a reference to it, abort it either on the next request or on teardown, is identical across frameworks, only the specific lifecycle hook you attach it to changes. If you're working across multiple frameworks, it's worth recognizing this as one pattern with different syntax rather than three separate things to learn.
What Happens If You Skip This Entirely
It's worth being concrete about the actual cost of skipping cancellation, since "memory leak" can sound abstract. In a single-page application where users navigate between views frequently, search components mounting and unmounting repeatedly without ever canceling their in-flight requests, each abandoned request keeps its promise chain alive until the network response eventually arrives, however long that takes. Under normal conditions this might only hold a handful of references briefly. Under degraded network conditions, or against a slow endpoint, or during heavy navigation, like a user quickly clicking through a list of search results and back, the number of orphaned pending promises can grow enough to show up as real memory pressure in a profiler, and it's a specifically annoying one to diagnose after the fact because the individual leaked references are small and the growth is gradual rather than a single obvious spike. Catching it in code review, by simply checking that every fetch tied to a component has a matching cleanup path, is far cheaper than catching it later in a production memory profile, where tracing an accumulation of small leaked references back to a specific missing abort call takes real, dedicated debugging time, usually starting from a vague symptom like gradually increasing tab memory rather than any obvious pointer to the actual cause.
Further Reading
MDN's AbortController documentation covers the full API surface, including using a single AbortSignal to cancel multiple related requests at once via AbortSignal.any() in newer browser versions. The Fetch API specification's section on aborting is the underlying reference if you want the precise behavior rather than a summary, and Wikipedia's entry on memory leaks is a useful refresher on why held references specifically, not just "using too much memory" in the abstract, are the actual mechanism worth watching for here. For the broader pattern this fits into, 137Foundry's guide to search autocomplete covers request cancellation alongside debouncing and caching as part of a complete implementation.
Top comments (0)