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.

7 min read

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.

javascriptjavascript
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.

javascriptjavascript
// 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.

javascriptjavascript
const controller = new AbortController();
console.log(controller.signal.aborted); // false
 
controller.abort();
console.log(controller.signal.aborted); // true
Property or methodDescription
controller.signalAn AbortSignal instance. Pass this to fetch
controller.abort(reason)Aborts the signal. The optional reason becomes the rejection value
signal.abortedBoolean, 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.

javascriptjavascript
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.

javascriptjavascript
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.

javascriptjavascript
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.

javascriptjavascript
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.

javascriptjavascript
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.

javascriptjavascript
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.

javascriptjavascript
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.

javascriptjavascript
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.

javascriptjavascript
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.

javascriptjavascript
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.

javascriptjavascript
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.

javascriptjavascript
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.

javascriptjavascript
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.

javascriptjavascript
// 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.

javascriptjavascript
// 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

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.
RunePowered by Rune AI

Frequently Asked Questions

What happens when I abort a fetch request?

The fetch promise rejects with an AbortError (DOMException with name 'AbortError'). The TCP connection is closed. You can catch this specific error to distinguish cancellation from actual failures.

Can I reuse an AbortController after aborting?

No. Once you call controller.abort(), the signal is permanently aborted. Create a new AbortController for each new request or group of requests.

Does aborting a fetch also cancel the server-side operation?

No. Aborting closes the client connection, but the server may continue processing the request. The server learns about the abort only when it tries to write a response to the closed connection.

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.