Skip to content
Codeloom
JavaScript

AbortController Patterns: Cancel Fetch, Timeouts, and Event Listeners

Master AbortController in JavaScript -- cancel fetch requests, debounce events, set timeouts, and build cancellable async workflows.

·6 min read · By Codeloom
Intermediate 10 min read

What you'll learn

  • How AbortController and AbortSignal work together
  • Patterns for cancelling fetch requests and setting timeouts
  • Advanced patterns for composing signals and cleaning up listeners

Prerequisites

  • JavaScript Promises and async/await
  • Familiarity with the Fetch API

AbortController is a built-in API for cancelling asynchronous operations. It was originally designed for cancelling fetch requests, but its uses extend far beyond that — event listeners, timeouts, streams, and any custom async workflow can be made cancellable.

The basics

An AbortController has two parts:

  • controller — the object you call .abort() on
  • signal — the AbortSignal you pass to cancellable operations
const controller = new AbortController();
const signal = controller.signal;

console.log(signal.aborted); // false

controller.abort();
console.log(signal.aborted); // true

Cancelling fetch requests

The most common use case. Pass the signal to fetch() and call abort() to cancel.

const controller = new AbortController();

fetch("https://api.example.com/data", { signal: controller.signal })
  .then((response) => response.json())
  .then((data) => console.log(data))
  .catch((error) => {
    if (error.name === "AbortError") {
      console.log("Fetch was cancelled");
    } else {
      console.error("Fetch failed:", error);
    }
  });

// Cancel the request after 100ms
setTimeout(() => controller.abort(), 100);

When aborted, fetch rejects with an AbortError. Always check error.name to distinguish cancellation from actual failures.

Pattern 1: Fetch with timeout

Use AbortSignal.timeout() for a built-in timeout mechanism, or build your own for older environments.

// Modern approach -- AbortSignal.timeout()
async function fetchWithTimeout(url, timeoutMs = 5000) {
  try {
    const response = await fetch(url, {
      signal: AbortSignal.timeout(timeoutMs),
    });
    return await response.json();
  } catch (err) {
    if (err.name === "TimeoutError") {
      console.error(`Request to ${url} timed out after ${timeoutMs}ms`);
    }
    throw err;
  }
}

// Manual approach (for older environments)
async function fetchWithTimeoutManual(url, timeoutMs = 5000) {
  const controller = new AbortController();
  const timeoutId = setTimeout(() => controller.abort(), timeoutMs);

  try {
    const response = await fetch(url, { signal: controller.signal });
    return await response.json();
  } catch (error) {
    if (error.name === "AbortError") {
      throw new Error(`Request timed out after ${timeoutMs}ms`);
    }
    throw error;
  } finally {
    clearTimeout(timeoutId);
  }
}

Pattern 2: Cancel previous requests (search autocomplete)

When building search autocomplete, cancel the previous request whenever the user types a new character.

let currentController = null;

async function search(query) {
  // Cancel any in-flight request
  if (currentController) {
    currentController.abort();
  }

  currentController = new AbortController();

  try {
    const response = await fetch(`/api/search?q=${encodeURIComponent(query)}`, {
      signal: currentController.signal,
    });
    const results = await response.json();
    displayResults(results);
  } catch (error) {
    if (error.name === "AbortError") {
      // Silently ignore -- a newer request replaced this one
      return;
    }
    console.error("Search failed:", error);
  }
}

// Wire up to input
document.querySelector("#search").addEventListener("input", (e) => {
  search(e.target.value);
});

Pattern 3: Removing event listeners

AbortSignal works with addEventListener, making cleanup much simpler than manually tracking and removing listeners.

const controller = new AbortController();

document.addEventListener(
  "click",
  (e) => console.log("clicked", e.target),
  { signal: controller.signal }
);

document.addEventListener(
  "keydown",
  (e) => console.log("key", e.key),
  { signal: controller.signal }
);

window.addEventListener(
  "resize",
  () => console.log("resized"),
  { signal: controller.signal }
);

// Later: remove ALL three listeners at once
controller.abort();

This is especially powerful in classes or modules that set up many listeners.

class ModalDialog {
  #controller = new AbortController();

  open() {
    const { signal } = this.#controller;

    document.addEventListener("keydown", (e) => {
      if (e.key === "Escape") this.close();
    }, { signal });

    this.overlay.addEventListener("click", () => {
      this.close();
    }, { signal });

    window.addEventListener("popstate", () => {
      this.close();
    }, { signal });
  }

  close() {
    this.#controller.abort(); // Removes all listeners at once
    this.element.remove();
  }
}

Pattern 4: Composing multiple signals

AbortSignal.any() creates a signal that aborts when any of its source signals abort. This is useful for combining user cancellation with timeouts.

async function fetchWithUserCancel(url, userSignal) {
  const timeoutSignal = AbortSignal.timeout(10000);
  const combinedSignal = AbortSignal.any([userSignal, timeoutSignal]);

  try {
    const response = await fetch(url, { signal: combinedSignal });
    return await response.json();
  } catch (error) {
    if (error.name === "AbortError") {
      if (userSignal.aborted) {
        console.log("User cancelled the request");
      } else {
        console.log("Request timed out");
      }
    }
    throw error;
  }
}

// Usage
const userController = new AbortController();
fetchWithUserCancel("/api/data", userController.signal);

// User clicks "Cancel" button
document.querySelector("#cancel").addEventListener("click", () => {
  userController.abort();
});

Pattern 5: Making custom async functions cancellable

You can check signal.aborted or listen for the abort event in your own async code.

function delay(ms, signal) {
  return new Promise((resolve, reject) => {
    if (signal?.aborted) {
      return reject(new DOMException("Delay cancelled", "AbortError"));
    }

    const timeoutId = setTimeout(resolve, ms);

    signal?.addEventListener("abort", () => {
      clearTimeout(timeoutId);
      reject(new DOMException("Delay cancelled", "AbortError"));
    });
  });
}

// Usage
const controller = new AbortController();

delay(5000, controller.signal)
  .then(() => console.log("Done waiting"))
  .catch((err) => console.log(err.message));

// Cancel after 1 second
setTimeout(() => controller.abort(), 1000);
// "Delay cancelled"

Pattern 6: React useEffect cleanup

AbortController is the standard pattern for cleaning up async effects in React.

function UserProfile({ userId }) {
  const [user, setUser] = useState(null);

  useEffect(() => {
    const controller = new AbortController();

    async function loadUser() {
      try {
        const response = await fetch(`/api/users/${userId}`, {
          signal: controller.signal,
        });
        const data = await response.json();
        setUser(data);
      } catch (error) {
        if (error.name !== "AbortError") {
          console.error("Failed to load user:", error);
        }
      }
    }

    loadUser();

    // Cleanup: abort when userId changes or component unmounts
    return () => controller.abort();
  }, [userId]);

  return user ? <div>{user.name}</div> : <div>Loading...</div>;
}

Pattern 7: Polling with cancellation

async function pollForStatus(url, intervalMs = 2000, signal) {
  while (!signal?.aborted) {
    try {
      const response = await fetch(url, { signal });
      const data = await response.json();

      if (data.status === "complete") {
        return data;
      }

      await delay(intervalMs, signal);
    } catch (err) {
      if (err.name === "AbortError") return null;
      throw err;
    }
  }
  return null;
}

// Start polling
const controller = new AbortController();
const result = pollForStatus("/api/job/123/status", 3000, controller.signal);

// Stop polling after 30 seconds
setTimeout(() => controller.abort(), 30000);

Abort reason

You can pass a reason to abort() to provide context about why the operation was cancelled.

const controller = new AbortController();

controller.signal.addEventListener("abort", () => {
  console.log("Reason:", controller.signal.reason);
});

controller.abort("User navigated away");
// "Reason: User navigated away"

// Or with a custom error
controller.abort(new Error("Session expired"));

Summary

AbortController is the standard cancellation mechanism in JavaScript:

  • Pass signal to fetch() to cancel HTTP requests.
  • Use AbortSignal.timeout() for built-in request timeouts.
  • Cancel previous requests in autocomplete and search patterns.
  • Pass signal to addEventListener for bulk listener cleanup.
  • Use AbortSignal.any() to combine multiple cancellation sources.
  • Make your own async APIs abortable by accepting a signal option.
  • In React, use AbortController in useEffect cleanup to prevent state updates on unmounted components.

Every async operation in your application should be cancellable. AbortController gives you a single, consistent pattern to achieve this without custom cancellation logic.