Back to Blog

AbortController Patterns Beyond Fetch: Cancellation Architecture for Complex UIs

March 7, 20263 min read9 views

AbortController Patterns Beyond Fetch: Cancellation Architecture for Complex UIs

AbortController Is a Universal Cancellation Primitive

Most developers know AbortController as "the thing that cancels fetch." But it's a general-purpose cancellation token that works with any async operation: Web Workers, event listeners, streams, timers, database transactions, and custom promise chains. It's the missing cancellation primitive JavaScript never had.


The AbortController/AbortSignal Architecture

┌──────────────────────────────────────────────────────────────────┐
│                      AbortController                             │
│                                                                  │
│  controller = new AbortController()                              │
│                                                                  │
│  ┌─────────────┐         ┌──────────────────────────────────┐   │
│  │ .abort()    │────────▶│ .signal (AbortSignal)             │   │
│  │ .abort(     │         │                                    │   │
│  │   reason)   │         │  .aborted: boolean                │   │
│  └─────────────┘         │  .reason: any                     │   │
│                          │  .throwIfAborted()                 │   │
│  One controller          │  .addEventListener('abort', fn)   │   │
│  controls the signal     │                                    │   │
│                          │  Passed to consumers:              │   │
│                          │  - fetch(url, { signal })          │   │
│                          │  - addEventListener(type, fn,      │   │
│                          │      { signal })                   │   │
│                          │  - stream.pipeTo(dest, { signal }) │   │
│                          │  - Custom async functions          │   │
│                          └──────────────────────────────────┘   │
│                                                                  │
│  KEY INSIGHT: The controller is held by the OWNER (the one      │
│  who decides when to cancel). The signal is passed to            │
│  CONSUMERS (the things being cancelled). Separation of           │
│  concerns — consumers can't cancel each other.                   │
└──────────────────────────────────────────────────────────────────┘

AbortSignal.any() and AbortSignal.timeout()

Signal Composition

// AbortSignal.timeout() — auto-cancels after duration:
const response = await fetch("/api/data", {
  signal: AbortSignal.timeout(5000), // Cancel after 5 seconds
});

// AbortSignal.any() — cancels when ANY of the signals abort:
const controller = new AbortController();

const signal = AbortSignal.any([
  controller.signal,              // Manual cancellation
  AbortSignal.timeout(10_000),    // Timeout after 10s
]);

// Usage: manually cancellable with automatic timeout
const response = await fetch("/api/data", { signal });

// Either works:
controller.abort(); // Manual cancel
// OR: 10 seconds pass → automatic cancel


// REAL PATTERN: Page-level controller + per-request timeout
class ApiClient {
  private pageController = new AbortController();

  async request<T>(url: string, options: RequestInit = {}): Promise<T> {
    const signal = AbortSignal.any([
      this.pageController.signal,        // Cancels on unmount/navigation
      AbortSignal.timeout(30_000),       // 30s timeout per request
      ...(options.signal ? [options.signal] : []), // Caller's own signal
    ]);

    const response = await fetch(url, { ...options, signal });

    if (!response.ok) {
      throw new Error(`HTTP ${response.status}`);
    }

    return response.json();
  }

  // Called on page leave or cleanup:
  cancelAll(): void {
    this.pageController.abort(new Error("Page navigated away"));
    this.pageController = new AbortController(); // Fresh for next page
  }
}

AbortController in React Effects

The Correct Cleanup Pattern

function useApi<T>(url: string): { data: T | null; loading: boolean; error: Error | null } {
  const [data, setData] = useState<T | null>(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<Error | null>(null);

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

    async function fetchData() {
      try {
        setLoading(true);
        setError(null);

        const response = await fetch(url, { signal: controller.signal });
        const json = await response.json();

        // Only update state if NOT aborted:
        if (!controller.signal.aborted) {
          setData(json);
          setLoading(false);
        }
      } catch (err) {
        // AbortError is expected on cleanup — don't treat as error:
        if (err instanceof DOMException && err.name === "AbortError") {
          return; // Component unmounted or URL changed — ignore
        }
        if (!controller.signal.aborted) {
          setError(err as Error);
          setLoading(false);
        }
      }
    }

    fetchData();

    // Cleanup: abort on unmount OR when url changes:
    return () => controller.abort();
  }, [url]);

  return { data, loading, error };
}

// WHY THIS MATTERS:
// Without abort, fast URL changes cause a race condition:
//
// 1. URL changes to "/api/a" → fetch starts
// 2. URL changes to "/api/b" → new fetch starts
// 3. Response for "/api/b" arrives → setData(b) ✅
// 4. Response for "/api/a" arrives LATE → setData(a) ❌ STALE DATA!
//
// With abort:
// 1. URL changes to "/api/a" → fetch starts
// 2. URL changes to "/api/b" → cleanup aborts "/api/a", new fetch starts
// 3. Response for "/api/b" arrives → setData(b) ✅
// 4. Fetch for "/api/a" was already aborted → no response, no state update

Cancelling Parallel API Calls on Route Change

function useDashboardData(dashboardId: string) {
  const [state, setState] = useState<DashboardState>({
    metrics: null,
    charts: null,
    alerts: null,
    loading: true,
  });

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

    async function loadDashboard() {
      try {
        setState((prev) => ({ ...prev, loading: true }));

        // Launch ALL requests in parallel with the SAME signal:
        const [metrics, charts, alerts] = await Promise.all([
          fetch(`/api/dashboards/${dashboardId}/metrics`, { signal }).then((r) => r.json()),
          fetch(`/api/dashboards/${dashboardId}/charts`, { signal }).then((r) => r.json()),
          fetch(`/api/dashboards/${dashboardId}/alerts`, { signal }).then((r) => r.json()),
        ]);

        if (!signal.aborted) {
          setState({ metrics, charts, alerts, loading: false });
        }
      } catch (err) {
        if (!signal.aborted) {
          setState((prev) => ({ ...prev, loading: false }));
        }
      }
    }

    loadDashboard();

    return () => {
      // All three parallel fetches cancelled with ONE abort:
      controller.abort();
    };
  }, [dashboardId]);

  return state;
}

Cancelling Web Workers

// Workers don't natively support AbortSignal, but we can bridge it:

class CancellableWorker {
  private worker: Worker;
  private pendingTasks = new Map<string, {
    resolve: (val: any) => void;
    reject: (err: any) => void;
  }>();

  constructor(scriptURL: string) {
    this.worker = new Worker(scriptURL);

    this.worker.onmessage = (e: MessageEvent) => {
      const { taskId, result, error } = e.data;
      const pending = this.pendingTasks.get(taskId);
      if (!pending) return;

      this.pendingTasks.delete(taskId);
      if (error) {
        pending.reject(new Error(error));
      } else {
        pending.resolve(result);
      }
    };
  }

  run<T>(task: string, payload: any, signal?: AbortSignal): Promise<T> {
    return new Promise((resolve, reject) => {
      const taskId = crypto.randomUUID();

      // If already aborted, reject immediately:
      if (signal?.aborted) {
        reject(signal.reason ?? new DOMException("Aborted", "AbortError"));
        return;
      }

      this.pendingTasks.set(taskId, { resolve, reject });

      // Listen for abort:
      const onAbort = () => {
        this.pendingTasks.delete(taskId);
        // Tell the worker to stop this task:
        this.worker.postMessage({ type: "cancel", taskId });
        reject(signal!.reason ?? new DOMException("Aborted", "AbortError"));
      };

      signal?.addEventListener("abort", onAbort, { once: true });

      // Send task to worker:
      this.worker.postMessage({ type: "task", taskId, task, payload });
    });
  }

  terminate(): void {
    this.worker.terminate();
    // Reject all pending tasks:
    for (const [, { reject }] of this.pendingTasks) {
      reject(new DOMException("Worker terminated", "AbortError"));
    }
    this.pendingTasks.clear();
  }
}

// Worker script (worker.ts):
const activeTasks = new Set<string>();

self.onmessage = (e: MessageEvent) => {
  const { type, taskId, task, payload } = e.data;

  if (type === "cancel") {
    activeTasks.delete(taskId);
    return;
  }

  if (type === "task") {
    activeTasks.add(taskId);

    // Periodically check if cancelled during long computation:
    function isCancelled(): boolean {
      return !activeTasks.has(taskId);
    }

    try {
      const result = executeTask(task, payload, isCancelled);
      if (activeTasks.has(taskId)) {
        self.postMessage({ taskId, result });
      }
    } catch (error: any) {
      if (activeTasks.has(taskId)) {
        self.postMessage({ taskId, error: error.message });
      }
    } finally {
      activeTasks.delete(taskId);
    }
  }
};

function executeTask(task: string, payload: any, isCancelled: () => boolean): any {
  if (task === "heavyComputation") {
    let result = 0;
    for (let i = 0; i < payload.iterations; i++) {
      if (i % 10000 === 0 && isCancelled()) {
        throw new Error("Cancelled");
      }
      result += Math.sqrt(i);
    }
    return result;
  }
}

Cancellable Promise Chains

// Build any async operation that respects AbortSignal:

function cancellableDelay(ms: number, signal?: AbortSignal): Promise<void> {
  return new Promise((resolve, reject) => {
    if (signal?.aborted) {
      reject(signal.reason);
      return;
    }

    const timer = setTimeout(resolve, ms);

    signal?.addEventListener(
      "abort",
      () => {
        clearTimeout(timer);
        reject(signal.reason ?? new DOMException("Aborted", "AbortError"));
      },
      { once: true }
    );
  });
}

// Cancellable retry with exponential backoff:
async function fetchWithRetry(
  url: string,
  options: RequestInit & { maxRetries?: number } = {}
): Promise<Response> {
  const { maxRetries = 3, signal, ...fetchOptions } = options;

  for (let attempt = 0; attempt <= maxRetries; attempt++) {
    try {
      const response = await fetch(url, { ...fetchOptions, signal });
      if (response.ok) return response;

      if (response.status >= 400 && response.status < 500) {
        throw new Error(`Client error: ${response.status}`);
      }
    } catch (err) {
      // If aborted, throw immediately — don't retry:
      if (signal?.aborted) throw err;
      if (err instanceof DOMException && err.name === "AbortError") throw err;

      if (attempt === maxRetries) throw err;
    }

    // Wait before retry (unless aborted):
    const delay = Math.min(1000 * Math.pow(2, attempt), 10_000);
    await cancellableDelay(delay, signal); // Abortable wait!
  }

  throw new Error("Unreachable");
}

// Usage:
const controller = new AbortController();

fetchWithRetry("/api/data", {
  signal: controller.signal,
  maxRetries: 5,
}).catch((err) => {
  if (err.name === "AbortError") {
    console.log("Request cancelled — all retries stopped");
  }
});

// Any time later — cancels even if mid-retry-delay:
controller.abort();

Request Cancellation Layer

// A centralized cancellation layer for your entire app
// that prevents race conditions across all API calls.

type RequestKey = string;

class RequestCancellationLayer {
  // Track active requests by key:
  private activeRequests = new Map<RequestKey, AbortController>();
  // Page-level controller for unmount:
  private pageController = new AbortController();

  // Deduplicated request: Same key → cancel previous, start new
  async request<T>(
    key: RequestKey,
    fetcher: (signal: AbortSignal) => Promise<T>
  ): Promise<T> {
    // Cancel any in-flight request with the same key:
    this.cancel(key);

    const controller = new AbortController();
    this.activeRequests.set(key, controller);

    // Compose with page-level signal:
    const signal = AbortSignal.any([
      controller.signal,
      this.pageController.signal,
    ]);

    try {
      const result = await fetcher(signal);
      return result;
    } finally {
      // Only clean up if this is still the active request for this key:
      if (this.activeRequests.get(key) === controller) {
        this.activeRequests.delete(key);
      }
    }
  }

  // Cancel a specific request:
  cancel(key: RequestKey): void {
    const controller = this.activeRequests.get(key);
    if (controller) {
      controller.abort(new DOMException("Superseded by new request", "AbortError"));
      this.activeRequests.delete(key);
    }
  }

  // Cancel all requests (page navigation):
  cancelAll(): void {
    for (const [key, controller] of this.activeRequests) {
      controller.abort();
    }
    this.activeRequests.clear();
    this.pageController.abort();
    this.pageController = new AbortController();
  }

  // Check if a key has an active request:
  isActive(key: RequestKey): boolean {
    return this.activeRequests.has(key);
  }
}

// React integration:
const cancellationLayer = new RequestCancellationLayer();

function useSearch(query: string) {
  const [results, setResults] = useState<SearchResult[]>([]);
  const [loading, setLoading] = useState(false);

  useEffect(() => {
    if (!query) {
      setResults([]);
      return;
    }

    setLoading(true);

    // Key: "search" — each new query cancels the previous search
    cancellationLayer
      .request("search", (signal) =>
        fetch(`/api/search?q=${encodeURIComponent(query)}`, { signal }).then((r) =>
          r.json()
        )
      )
      .then((data) => {
        setResults(data.results);
        setLoading(false);
      })
      .catch((err) => {
        if (err.name !== "AbortError") {
          setLoading(false);
        }
      });

    // No need for cleanup here — the cancellation layer handles it
    // via the "search" key deduplication.
  }, [query]);

  return { results, loading };
}

// On route change:
function useRouteCleanup() {
  useEffect(() => {
    return () => cancellationLayer.cancelAll();
  }, []);
}

AbortSignal for Event Listeners

// Clean up event listeners without manual removeEventListener:

function setupGlobalShortcuts(signal: AbortSignal): void {
  document.addEventListener(
    "keydown",
    (e: KeyboardEvent) => {
      if (e.key === "Escape") closeModal();
      if (e.metaKey && e.key === "k") openSearch();
    },
    { signal } // Auto-removed when signal aborts!
  );

  window.addEventListener("resize", handleResize, { signal });
  window.addEventListener("online", syncData, { signal });
  window.addEventListener("offline", showOfflineBanner, { signal });

  // All four listeners removed with ONE abort:
}

// React pattern:
function useGlobalShortcuts() {
  useEffect(() => {
    const controller = new AbortController();
    setupGlobalShortcuts(controller.signal);
    return () => controller.abort(); // Clean up ALL listeners
  }, []);
}

Interview Q&A

Q: How does AbortController work beyond fetch? A: AbortController is a general-purpose cancellation token with two parts: the controller (caller holds this, calls .abort()) and the signal (passed to consumers). The signal is an EventTarget that fires an "abort" event. Any API or custom code can accept a signal by listening for the abort event. Built-in support includes fetch(), addEventListener() (auto-removal), ReadableStream.pipeTo(), and SubtleCrypto operations. For custom async work, you check signal.aborted at async boundaries or listen for the abort event to clean up.

Q: How does AbortSignal.any() work and when would you use it? A: AbortSignal.any([signal1, signal2, ...]) creates a composite signal that aborts when ANY of the input signals abort. The classic pattern is combining a manual controller signal with AbortSignal.timeout(): the request cancels if either the user navigates away OR 10 seconds pass. You can also compose page-level signals with per-request signals, ensuring all requests cancel on page leave while each request also has its own cancellation control. The ability to signal is reason propagates from whichever source signal triggered the abort.

Q: How do you prevent race conditions in React effects with fetching? A: Create an AbortController inside the useEffect, pass its signal to fetch, and call controller.abort() in the effect's cleanup function. When the dependency changes, React runs the cleanup (aborting the old request) before starting the new effect (with a new controller). This guarantees only the latest request's response updates state. Without this, slow responses from earlier requests can arrive after newer ones, overwriting fresh data with stale data. Always check signal.aborted before calling setState, and ignore AbortError in catch blocks since it's expected behavior.

Q: How would you design a request cancellation layer for a large app? A: Use a centralized RequestCancellationLayer that maps string keys to AbortController instances. When a new request starts with the same key, the layer automatically aborts the previous one. Compose per-request signals with a page-level signal using AbortSignal.any(), so route changes cancel everything. The layer provides request(key, fetcher) for deduplication, cancel(key) for targeted cancellation, and cancelAll() for navigation cleanup. This eliminates the need for per-component abort logic and prevents race conditions application-wide. The key design principle is separation: the layer owns cancellation logic, components just specify request keys.

What did you think?

© 2026 Vidhya Sagar Thakur. All rights reserved.