Canceling Fetch Requests in JavaScript: Full Guide
Learn how to cancel in-progress fetch requests with AbortController. Stop stale API calls, cancel uploads, handle timeouts cleanly, and avoid race conditions in async UIs.
Canceling fetch requests in JavaScript relies on AbortController, the standard web API for stopping asynchronous operations mid-flight. You create a controller, pass its signal to fetch, and call controller.abort() when you want to cancel the in-flight request.
const controller = new AbortController();
fetch("https://api.example.com/slow-endpoint", { signal: controller.signal })
.then((response) => response.json())
.then((data) => console.log(data))
.catch((error) => {
if (error.name === "AbortError") {
console.log("Request was cancelled");
} else {
console.error("Request failed:", error.message);
}
});Calling abort after a short delay demonstrates the cancellation directly, without needing a real slow endpoint to test against and without needing to wait for the request to actually finish on its own. This same setTimeout trick is a handy way to test cancellation logic locally before wiring it up to a real user action.
// Cancel the request after 2 seconds
setTimeout(() => controller.abort(), 2000);When you call abort, the fetch promise rejects with an AbortError. The TCP connection is closed, and the error's name property is set to "AbortError", which lets you distinguish cancellation from network failures and server errors. This distinction matters in a catch block, since a cancellation is an expected outcome the code chose to trigger, not a bug that needs to be logged or shown to the user as a failure.
AbortController basics
A controller and its signal are two sides of the same object: the controller is what you call abort on, and the signal is what you hand to fetch. This split exists so that a function which only needs to observe cancellation, like fetch itself, never gets a reference powerful enough to trigger it. Checking the signal's aborted property shows the state flipping the moment abort is called.
const controller = new AbortController();
console.log(controller.signal.aborted); // false
controller.abort();
console.log(controller.signal.aborted); // true| Property or method | Description |
|---|---|
| controller.signal | An AbortSignal instance. Pass this to fetch |
| controller.abort(reason) | Aborts the signal. The optional reason becomes the rejection value |
| signal.aborted | Boolean, true after abort is called |
| signal.addEventListener("abort", fn) | Fires when the signal is aborted |
You can also pass an optional reason to abort, which becomes available to code that inspects the resulting error. This is useful when a single controller can be aborted for more than one cause, such as a timeout versus an explicit user cancellation, and downstream code wants to react differently depending on which one happened.
const controller = new AbortController();
controller.abort("User navigated away");
fetch(url, { signal: controller.signal }).catch((error) => {
console.log(error.name); // "AbortError"
// In modern browsers, error.cause may contain the reason
});Aborting on user navigation or new input
The most common use case is a search box: a user types, triggering a fetch, and if they type again before the first fetch finishes, the first one needs to be cancelled. A variable outside the function tracks the current controller across calls.
let abortController = null;
function cancelPreviousSearch() {
if (abortController) {
abortController.abort();
}
abortController = new AbortController();
return abortController.signal;
}The search function itself calls that helper first, then uses the fresh signal for the actual request, so the cancellation logic stays completely separate from the fetch logic.
async function search(query) {
const signal = cancelPreviousSearch();
try {
const response = await fetch(`/api/search?q=${encodeURIComponent(query)}`, { signal });
if (!response.ok) throw new Error(`Search failed: ${response.status}`);
renderResults(await response.json());
} catch (error) {
if (error.name === "AbortError") return; // Superseded by a newer query
console.error("Search error:", error.message);
}
}Wiring the function to the search input's own event covers the whole flow: type, cancel the previous request, fetch again, all without any debounce timer needed to get a correct result.
searchInput.addEventListener("input", (event) => search(event.target.value));Each keystroke cancels the previous request. Only the results for the latest query are rendered. Without AbortController, a slow response from an old query could overwrite the results for the current query, a bug commonly called a race condition since the outcome depends on which request happens to finish last rather than which one was sent last.
Cancelling multiple requests with one controller
A single AbortController can cancel many requests at once. Pass the same signal to every fetch call, and one abort call stops all of them together, since each fetch simply checks the same underlying signal independently rather than needing to be told about the cancellation individually. Fetching the three pieces of dashboard data in parallel is the same Promise.all pattern used elsewhere, just with a shared signal added to each call.
async function fetchDashboardData(userId, signal) {
const [user, posts, notifications] = await Promise.all([
fetch(`/api/users/${userId}`, { signal }).then((r) => r.json()),
fetch(`/api/posts?userId=${userId}`, { signal }).then((r) => r.json()),
fetch(`/api/notifications`, { signal }).then((r) => r.json()),
]);
return { user, posts, notifications };
}The outer function adds the timeout and error handling around that call, so the parallel-fetch logic above stays focused on just the requests themselves rather than mixing timing concerns into the same function.
async function loadDashboard(userId) {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 10000);
try {
return await fetchDashboardData(userId, controller.signal);
} catch (error) {
if (error.name === "AbortError") throw new Error("Dashboard load timed out or was cancelled");
throw error;
} finally {
clearTimeout(timeoutId);
}
}One abort call cancels all three fetches at once. This pattern is useful when a component unmounts, a dialog closes, or a user navigates to a different page before all the requests finish, since there is no need to track three separate controllers just to cancel three related requests together.
Built-in timeout with AbortController + setTimeout
Combine AbortController with setTimeout for a clean timeout pattern that does not need Promise.race.
function fetchWithTimeout(url, options = {}, timeoutMs = 5000) {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
return fetch(url, { ...options, signal: controller.signal }).finally(() => {
clearTimeout(timeoutId);
});
}Calling that wrapper looks exactly like a normal fetch call from the caller's side, except a slow response now fails on its own instead of hanging indefinitely.
async function loadData() {
try {
const response = await fetchWithTimeout("https://api.example.com/data", {}, 3000);
return response.json();
} catch (error) {
if (error.name === "AbortError") {
throw new Error("Request timed out after 3 seconds");
}
throw error;
}
}The finally callback clears the timeout to prevent it from lingering after the request completes. If the fetch finishes before the timeout, the timer is cleaned up, and if the timer fires first, abort cancels the fetch instead.
Skipping that cleanup step would leave a dangling timer that fires later and calls abort on a controller nobody cares about anymore, which is harmless but still worth avoiding.
Compared to Promise.race, this approach is simpler and does not leave a dangling timer promise. The cancellation is handled by the platform's abort mechanism rather than a promise race between two competing outcomes.
AbortController with AbortSignal.timeout
Modern browsers support AbortSignal.timeout() as a shorthand for the timeout pattern above, skipping the manual controller and setTimeout entirely.
const response = await fetch(url, {
signal: AbortSignal.timeout(5000),
});This creates a signal that automatically aborts after 5 seconds. It is cleaner than managing a controller and setTimeout manually, though you cannot call abort early if you need to cancel for reasons other than a timeout. For a request that only ever needs a timeout and nothing else, this one-liner replaces the entire fetchWithTimeout helper shown earlier, at the cost of losing the ability to cancel it for any other reason.
Aborting file uploads
Uploads can also be cancelled. The signal works the same way as any other fetch call, letting a cancel button stop an in-progress upload.
async function sendUploadRequest(file, signal) {
const formData = new FormData();
formData.append("file", file);
const response = await fetch("/api/upload", { method: "POST", body: formData, signal });
if (!response.ok) {
throw new Error(`Upload failed: ${response.status}`);
}
return response.json();
}The outer function wires up the controller and the cancel button, then leans on that helper for the actual request.
async function uploadFileWithCancel(file) {
const controller = new AbortController();
showCancelButton(() => controller.abort());
try {
return await sendUploadRequest(file, controller.signal);
} catch (error) {
if (error.name === "AbortError") return null;
throw error;
} finally {
hideCancelButton();
}
}Cancelling an upload closes the connection, and the server sees a truncated request. Whether it keeps the partial upload or discards it depends entirely on the server's own implementation, not on anything the client controls. Some servers clean up partial files automatically on a closed connection, while others need an explicit cleanup job to remove orphaned uploads left behind by cancellations.
Handling AbortError correctly
An AbortError is not a real error. It is a signal that the operation was intentionally stopped, so always check for it before treating a rejection as a genuine failure.
async function safeFetch(url, options = {}) {
try {
const response = await fetch(url, options);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.json();
} catch (error) {
if (error.name === "AbortError") {
throw error; // Let the caller decide what cancellation means here
}
console.error("Fetch failed:", error.message);
throw error;
}
}A common pattern re-throws the AbortError so the caller can distinguish it, but treats other errors as real failures worth showing the user. Re-throwing instead of swallowing it inside the helper keeps the decision about what cancellation means in one place, closest to whoever actually triggered the cancellation in the first place.
try {
const data = await safeFetch(url, { signal });
render(data);
} catch (error) {
if (error.name === "AbortError") {
return; // Silent, expected
}
showErrorMessage(error.message); // Real failure
}Common AbortController mistakes
Forgetting to create a new controller per request. Once aborted, a controller's signal stays aborted forever. Reusing it means every subsequent fetch is immediately cancelled.
// Mistake: reusing an aborted controller
const staleController = new AbortController();
staleController.abort();
fetch(url, { signal: staleController.signal }); // Instantly aborted!The fix is simply to create a fresh controller for each new operation instead of holding onto one that has already fired, since there is no way to reset an aborted signal back to its unaborted state.
// Correct: create a new controller for each operation
const freshController = new AbortController();
fetch(url, { signal: freshController.signal });Not catching AbortError. If you do not handle the rejection, it becomes an unhandled promise rejection, the exact same failure mode covered for any other uncaught async error. Always have a catch or try...catch for fetch calls with signals, since a cancellable request is still a request that can reject.
Using abort for cleanup that should always happen. Abort cancels the request, but it does not replace finally blocks. If you need to hide a spinner regardless of outcome, use finally, not the abort handler.
Calling abort after the request already completed. This is harmless but unnecessary. If the fetch already resolved, calling abort on the controller has no effect on that request.
For request timeout patterns using Promise.race, see How to Use Promise Race in JS: Complete Guide. For handling errors from cancelled and failed requests, see Handling Async Errors with Try Catch in JS Guide.
Rune AI
Key Insights
- Create an AbortController, pass its signal to fetch, and call abort() to cancel.
- A cancelled fetch rejects with an AbortError. Check error.name === 'AbortError' to identify it.
- One AbortController can cancel multiple requests by sharing the same signal.
- Combine AbortController with setTimeout for built-in request timeouts.
- Always abort in-progress fetches when the user navigates away or new input supersedes the request.
Frequently Asked Questions
What happens when I abort a fetch request?
Can I reuse an AbortController after aborting?
Does aborting a fetch also cancel the server-side operation?
Conclusion
AbortController is the standard way to cancel fetch requests. Pass its signal to fetch, call controller.abort() when you need to cancel, and catch the AbortError to handle cancellation gracefully. Every long-running or user-triggered fetch should have an abort path.
More in this topic
Using Decorators for Logging in JS Architecture
Learn how JavaScript decorators wrap class methods to add logging without touching the original code, including setup with Babel and how execution order works.
JavaScript While Loop Explained: A Complete Guide
A while loop repeats code as long as a condition stays true. Learn the syntax, see practical examples, and understand when a while loop is the right choice over a for loop.
JavaScript Loops Tutorial: for, while, do while
Loops let JavaScript repeat code without writing it twice. Learn the three core loop types, what each one does, and when to choose one over another.