Canceling Fetch Requests in JavaScript Full Guide

A complete guide to canceling Fetch requests in JavaScript. Covers AbortController fundamentals for Fetch, canceling on user navigation, race condition prevention in search inputs, canceling parallel requests, implementing request timeouts, building a FetchManager class for lifecycle tracking, and cleanup patterns for single-page applications.

JavaScriptintermediate
14 min read

The Fetch API does not have a built-in cancel method. Instead, you pass an AbortSignal to the fetch() call and trigger it via the associated AbortController. This guide focuses specifically on Fetch cancellation patterns: preventing stale responses, canceling on navigation, race condition elimination, timeout implementation, and lifecycle management.

Why Cancel Fetch Requests?

ScenarioProblem Without CancelSolution
User types in searchStale results overwrite current resultsCancel previous request
User navigates awayWasted bandwidth, potential state updates on unmounted componentCancel on route change
Request takes too longUser waits indefinitelyCancel after timeout
Component unmountMemory leak, setState on unmounted componentCancel in cleanup
Duplicate submissionsDouble form post, duplicate dataCancel previous, keep latest

Basic Cancel Pattern

javascriptjavascript
const controller = new AbortController();
 
fetch("/api/users", { signal: controller.signal })
  .then((response) => response.json())
  .then((data) => console.log(data))
  .catch((error) => {
    if (error.name === "AbortError") {
      console.log("Fetch was canceled");
    } else {
      console.error("Fetch failed:", error);
    }
  });
 
// Cancel the request
controller.abort();

When abort() is called, the Fetch promise rejects with a DOMException where name is "AbortError".

Cancel on User Navigation

javascriptjavascript
class PageController {
  constructor() {
    this.controller = null;
  }
 
  async loadPage(url) {
    // Cancel any pending request from the previous page
    if (this.controller) {
      this.controller.abort();
    }
 
    this.controller = new AbortController();
 
    try {
      const response = await fetch(url, { signal: this.controller.signal });
      const html = await response.text();
      document.getElementById("content").innerHTML = html;
    } catch (error) {
      if (error.name === "AbortError") return; // Navigated away, ignore
      document.getElementById("content").innerHTML = "<p>Failed to load page</p>";
    }
  }
 
  destroy() {
    if (this.controller) this.controller.abort();
  }
}
 
const page = new PageController();
 
// SPA navigation
document.querySelectorAll("nav a").forEach((link) => {
  link.addEventListener("click", (e) => {
    e.preventDefault();
    page.loadPage(link.href);
  });
});

The classic race condition: user types "cat", then "car". The "car" request completes before "cat", but then the slower "cat" response arrives and overwrites the correct "car" results.

javascriptjavascript
class SearchController {
  constructor(resultsContainer) {
    this.container = resultsContainer;
    this.controller = null;
    this.currentQuery = "";
  }
 
  async search(query) {
    // Cancel any in-flight search
    if (this.controller) {
      this.controller.abort();
    }
 
    this.controller = new AbortController();
    this.currentQuery = query;
 
    if (!query.trim()) {
      this.container.innerHTML = "";
      return;
    }
 
    try {
      const response = await fetch(
        `/api/search?q=${encodeURIComponent(query)}`,
        { signal: this.controller.signal }
      );
 
      if (!response.ok) throw new Error(`HTTP ${response.status}`);
      const data = await response.json();
 
      // Double-check: only render if this is still the current query
      if (query === this.currentQuery) {
        this.renderResults(data.results);
      }
    } catch (error) {
      if (error.name === "AbortError") return;
      this.container.innerHTML = "<p>Search failed</p>";
    }
  }
 
  renderResults(results) {
    this.container.innerHTML = results
      .map((r) => `<div class="result"><h3>${r.title}</h3><p>${r.excerpt}</p></div>`)
      .join("");
  }
 
  cancel() {
    if (this.controller) this.controller.abort();
  }
}

Canceling Parallel Requests

javascriptjavascript
async function loadDashboardData(signal) {
  const endpoints = [
    "/api/user/profile",
    "/api/user/projects",
    "/api/notifications",
    "/api/analytics",
  ];
 
  const requests = endpoints.map((url) =>
    fetch(url, { signal }).then((r) => r.json())
  );
 
  try {
    const results = await Promise.allSettled(requests);
 
    return {
      profile: results[0].status === "fulfilled" ? results[0].value : null,
      projects: results[1].status === "fulfilled" ? results[1].value : [],
      notifications: results[2].status === "fulfilled" ? results[2].value : [],
      analytics: results[3].status === "fulfilled" ? results[3].value : null,
    };
  } catch (error) {
    if (error.name === "AbortError") {
      console.log("All dashboard requests canceled");
      return null;
    }
    throw error;
  }
}
 
// Usage
const controller = new AbortController();
const data = loadDashboardData(controller.signal);
 
// Cancel everything at once
controller.abort();

Fetch With Timeout

javascriptjavascript
async function fetchWithTimeout(url, options = {}) {
  const { timeout = 10000, ...fetchOptions } = options;
  const controller = new AbortController();
 
  // If user passed a signal, combine with timeout
  if (fetchOptions.signal) {
    fetchOptions.signal.addEventListener("abort", () => controller.abort());
  }
 
  const timeoutId = setTimeout(() => {
    controller.abort();
  }, timeout);
 
  try {
    const response = await fetch(url, {
      ...fetchOptions,
      signal: controller.signal,
    });
    clearTimeout(timeoutId);
    return response;
  } catch (error) {
    clearTimeout(timeoutId);
 
    if (error.name === "AbortError" && !fetchOptions.signal?.aborted) {
      throw new Error(`Request timed out after ${timeout}ms`);
    }
    throw error;
  }
}
 
// Usage
const response = await fetchWithTimeout("/api/slow-endpoint", { timeout: 5000 });

Modern Alternative: AbortSignal.timeout()

javascriptjavascript
const response = await fetch("/api/data", {
  signal: AbortSignal.timeout(5000),
});

FetchManager: Track and Cancel All Requests

javascriptjavascript
class FetchManager {
  constructor() {
    this.controllers = new Map();
    this.requestId = 0;
  }
 
  async fetch(url, options = {}) {
    const id = ++this.requestId;
    const controller = new AbortController();
    this.controllers.set(id, { controller, url });
 
    try {
      const response = await fetch(url, {
        ...options,
        signal: controller.signal,
      });
 
      this.controllers.delete(id);
      return response;
    } catch (error) {
      this.controllers.delete(id);
      throw error;
    }
  }
 
  cancel(id) {
    const entry = this.controllers.get(id);
    if (entry) {
      entry.controller.abort();
      this.controllers.delete(id);
    }
  }
 
  cancelAll() {
    for (const [id, { controller }] of this.controllers) {
      controller.abort();
    }
    this.controllers.clear();
  }
 
  getPending() {
    return Array.from(this.controllers.entries()).map(([id, { url }]) => ({
      id,
      url,
    }));
  }
}
 
const manager = new FetchManager();
 
// Track all fetches
const response = await manager.fetch("/api/data");
 
// View pending requests
console.log(manager.getPending());
 
// Cancel everything on page unload
window.addEventListener("beforeunload", () => manager.cancelAll());

SPA Cleanup Patterns

Per-Route Controller

javascriptjavascript
class Router {
  constructor() {
    this.currentController = null;
  }
 
  navigate(path) {
    // Cancel all pending requests from the previous route
    if (this.currentController) {
      this.currentController.abort();
    }
 
    this.currentController = new AbortController();
    const signal = this.currentController.signal;
 
    // Pass signal to the route handler
    this.loadRoute(path, signal);
  }
 
  async loadRoute(path, signal) {
    try {
      const data = await fetch(`/api${path}`, { signal }).then((r) => r.json());
      this.render(data);
    } catch (error) {
      if (error.name === "AbortError") return;
      this.renderError(error);
    }
  }
 
  render(data) { /* ... */ }
  renderError(error) { /* ... */ }
}

Component Lifecycle

javascriptjavascript
class DataWidget {
  constructor(element) {
    this.element = element;
    this.controller = new AbortController();
  }
 
  async load() {
    try {
      const res = await fetch("/api/widget-data", {
        signal: this.controller.signal,
      });
      const data = await res.json();
      this.element.innerHTML = this.render(data);
    } catch (error) {
      if (error.name === "AbortError") return;
      this.element.innerHTML = "<p>Failed to load</p>";
    }
  }
 
  destroy() {
    this.controller.abort();
    this.element.innerHTML = "";
  }
 
  render(data) { return `<p>${data.value}</p>`; }
}
Rune AI

Rune AI

Key Insights

  • One abort cancels many: Pass the same signal to multiple fetch() calls to cancel an entire group with a single abort() call
  • Always check for AbortError: In every catch block, check error.name === "AbortError" to silently ignore intentional cancellations
  • Clear timeouts in finally: Prevent accidental aborts after successful responses by clearing setTimeout in the finally block
  • Cancel on route change in SPAs: Store the controller per route and abort in the cleanup/navigation handler to prevent stale renders
  • Double-check query identity: After an async response, verify the query still matches the current query before rendering to prevent visual flicker
RunePowered by Rune AI

Frequently Asked Questions

Does canceling a Fetch save bandwidth?

Yes, partially. The browser closes the TCP connection, so no more data is downloaded. However, the server may have already started processing and sending the response. The savings depend on when you cancel relative to the response delivery.

Can I cancel a Fetch after the headers arrive but before the body?

Yes. If you have called `response.json()` or `response.text()` and the body is still streaming, calling `abort()` will cancel the body download and reject the body-reading promise with an `AbortError`.

Why do I get AbortError even after the response arrives?

If you call `abort()` between receiving the response and completing `response.json()`, the JSON parsing is canceled. Always clear your timeout in the `finally` block to prevent this.

How do I cancel Fetch in React useEffect?

Create the `AbortController` inside the effect and return a cleanup function that calls `controller.abort()`. React calls the cleanup function when the component unmounts or the effect re-runs.

Is there a performance cost to creating many AbortControllers?

No meaningful cost. `AbortController` instances are lightweight objects. Create a new one for each request or request group without concern.

Conclusion

Canceling Fetch requests prevents stale data rendering, saves bandwidth, and avoids state updates on destroyed components. The core pattern is: create an AbortController, pass signal to fetch(), call abort() on cleanup. For AbortController beyond Fetch (event listeners, computed workflows), see using AbortController in JS complete tutorial. For the retry pattern that works with cancellation, see API retry patterns in JavaScript full tutorial. For the event loop that schedules these async operations, see the JS event loop architecture complete guide.