Docs

16.7-AbortController-Cancellation

16.7 AbortController & Cancellation

Overview

AbortController provides a standard way to cancel asynchronous operations in JavaScript. It's essential for managing long-running requests, preventing memory leaks, and improving user experience by allowing users to cancel operations they no longer need.

Learning Objectives

By the end of this section, you will:

  • Understand the AbortController and AbortSignal APIs
  • Cancel fetch requests and other async operations
  • Implement timeout patterns with AbortController
  • Handle abort events and cleanup properly
  • Create cancellable custom async operations
  • Combine multiple abort signals

Prerequisites

  • Understanding of Promises and async/await
  • Familiarity with the Fetch API
  • Basic knowledge of event handling

1. AbortController Basics

Creating and Using AbortController

// Create an AbortController
const controller = new AbortController();
const signal = controller.signal;

// Pass signal to fetch
fetch('/api/data', { 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 error:', error);
    }
  });

// Cancel the request
controller.abort();

AbortSignal Properties and Events

const controller = new AbortController();
const signal = controller.signal;

// Check if already aborted
console.log(signal.aborted); // false

// Listen for abort event
signal.addEventListener('abort', () => {
  console.log('Operation was aborted');
  console.log('Reason:', signal.reason);
});

// Abort with a custom reason
controller.abort('User cancelled the operation');
console.log(signal.aborted); // true
console.log(signal.reason); // 'User cancelled the operation'

2. Cancelling Fetch Requests

Basic Fetch Cancellation

async function fetchWithCancel(url) {
  const controller = new AbortController();

  const fetchPromise = fetch(url, {
    signal: controller.signal,
  });

  // Return both the promise and cancel function
  return {
    promise: fetchPromise.then((r) => r.json()),
    cancel: () => controller.abort(),
  };
}

// Usage
const { promise, cancel } = fetchWithCancel('/api/large-data');

// Cancel after 5 seconds if not complete
setTimeout(cancel, 5000);

try {
  const data = await promise;
  console.log(data);
} catch (error) {
  if (error.name === 'AbortError') {
    console.log('Request was cancelled');
  }
}

Cancel on Component Unmount (React Pattern)

function useFetch(url) {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

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

    async function fetchData() {
      try {
        setLoading(true);
        const response = await fetch(url, {
          signal: controller.signal,
        });
        const result = await response.json();
        setData(result);
        setError(null);
      } catch (err) {
        if (err.name !== 'AbortError') {
          setError(err);
        }
      } finally {
        setLoading(false);
      }
    }

    fetchData();

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

  return { data, loading, error };
}

3. Timeout Patterns

Using AbortSignal.timeout()

// Modern approach (ES2022+)
try {
  const response = await fetch('/api/data', {
    signal: AbortSignal.timeout(5000), // 5 second timeout
  });
  const data = await response.json();
} catch (error) {
  if (error.name === 'TimeoutError') {
    console.log('Request timed out');
  } else if (error.name === 'AbortError') {
    console.log('Request was aborted');
  }
}

Custom Timeout Implementation

function fetchWithTimeout(url, options = {}, timeout = 5000) {
  const controller = new AbortController();
  const signal = controller.signal;

  // Set up timeout
  const timeoutId = setTimeout(() => {
    controller.abort('Request timeout');
  }, timeout);

  return fetch(url, { ...options, signal }).finally(() =>
    clearTimeout(timeoutId)
  );
}

// Usage
try {
  const response = await fetchWithTimeout('/api/slow-endpoint', {}, 3000);
  const data = await response.json();
} catch (error) {
  console.log(error.name, error.message);
}

Combining Timeout with Manual Abort

function createCancellableFetch(url, timeout = 10000) {
  const controller = new AbortController();
  const timeoutId = setTimeout(() => {
    controller.abort('Timeout exceeded');
  }, timeout);

  const promise = fetch(url, { signal: controller.signal })
    .then((response) => {
      clearTimeout(timeoutId);
      return response.json();
    })
    .catch((error) => {
      clearTimeout(timeoutId);
      throw error;
    });

  return {
    promise,
    cancel: (reason = 'Cancelled by user') => {
      clearTimeout(timeoutId);
      controller.abort(reason);
    },
  };
}

4. Combining Multiple Signals

AbortSignal.any() (ES2024)

// Cancel if either timeout OR manual abort
const controller = new AbortController();

const combinedSignal = AbortSignal.any([
  controller.signal,
  AbortSignal.timeout(5000),
]);

fetch('/api/data', { signal: combinedSignal })
  .then((r) => r.json())
  .then((data) => console.log(data))
  .catch((error) => {
    console.log('Aborted:', error.message);
  });

// Can still manually abort before timeout
controller.abort('User cancelled');

Polyfill for AbortSignal.any()

function combineSignals(...signals) {
  const controller = new AbortController();

  function onAbort() {
    controller.abort(this.reason);
    cleanup();
  }

  function cleanup() {
    signals.forEach((signal) => {
      signal.removeEventListener('abort', onAbort);
    });
  }

  signals.forEach((signal) => {
    if (signal.aborted) {
      controller.abort(signal.reason);
    } else {
      signal.addEventListener('abort', onAbort);
    }
  });

  return controller.signal;
}

// Usage
const userController = new AbortController();
const timeoutSignal = AbortSignal.timeout(5000);
const combined = combineSignals(userController.signal, timeoutSignal);

5. Cancellable Custom Operations

Making Any Async Operation Cancellable

function cancellable(asyncFn) {
  return function (...args) {
    const controller = new AbortController();

    const promise = new Promise(async (resolve, reject) => {
      controller.signal.addEventListener('abort', () => {
        reject(
          new DOMException(controller.signal.reason || 'Aborted', 'AbortError')
        );
      });

      try {
        const result = await asyncFn(...args, controller.signal);
        resolve(result);
      } catch (error) {
        reject(error);
      }
    });

    return {
      promise,
      cancel: (reason) => controller.abort(reason),
    };
  };
}

// Usage
const cancellableOperation = cancellable(async (data, signal) => {
  // Check if aborted before starting
  if (signal.aborted) {
    throw new DOMException('Aborted', 'AbortError');
  }

  // Simulate long operation with checkpoints
  await step1(data);

  if (signal.aborted) throw new DOMException('Aborted', 'AbortError');

  await step2(data);

  if (signal.aborted) throw new DOMException('Aborted', 'AbortError');

  return await step3(data);
});

const { promise, cancel } = cancellableOperation(myData);

Cancellable Promise Utility

class CancellablePromise {
  constructor(executor) {
    this.controller = new AbortController();

    this.promise = new Promise((resolve, reject) => {
      this.controller.signal.addEventListener('abort', () => {
        reject(
          new DOMException(
            this.controller.signal.reason || 'Cancelled',
            'AbortError'
          )
        );
      });

      executor(resolve, reject, this.controller.signal);
    });
  }

  cancel(reason = 'Cancelled') {
    this.controller.abort(reason);
  }

  then(onFulfilled, onRejected) {
    return this.promise.then(onFulfilled, onRejected);
  }

  catch(onRejected) {
    return this.promise.catch(onRejected);
  }

  finally(onFinally) {
    return this.promise.finally(onFinally);
  }
}

// Usage
const operation = new CancellablePromise(async (resolve, reject, signal) => {
  try {
    const result = await someAsyncWork(signal);
    resolve(result);
  } catch (error) {
    reject(error);
  }
});

operation.then((result) => console.log(result));
operation.cancel('No longer needed');

6. Event Listener Removal

Automatic Cleanup with Signal

const controller = new AbortController();

// Add event listeners with abort signal
window.addEventListener('resize', handleResize, {
  signal: controller.signal,
});

document.addEventListener('click', handleClick, {
  signal: controller.signal,
});

document.addEventListener('keydown', handleKeydown, {
  signal: controller.signal,
});

// Remove all listeners at once
function cleanup() {
  controller.abort();
}

// Useful in component cleanup
class MyComponent {
  constructor() {
    this.abortController = new AbortController();
  }

  mount() {
    const { signal } = this.abortController;

    this.element.addEventListener('click', this.onClick, { signal });
    window.addEventListener('scroll', this.onScroll, { signal });
    document.addEventListener('keyup', this.onKeyUp, { signal });
  }

  unmount() {
    // Single call removes all listeners
    this.abortController.abort();
  }
}

7. Real-World Patterns

Search with Debounce and Cancellation

class SearchController {
  constructor(searchFn, debounceMs = 300) {
    this.searchFn = searchFn;
    this.debounceMs = debounceMs;
    this.currentController = null;
    this.debounceTimer = null;
  }

  search(query) {
    // Clear previous debounce timer
    if (this.debounceTimer) {
      clearTimeout(this.debounceTimer);
    }

    // Cancel previous request
    if (this.currentController) {
      this.currentController.abort('New search started');
    }

    return new Promise((resolve, reject) => {
      this.debounceTimer = setTimeout(async () => {
        this.currentController = new AbortController();

        try {
          const result = await this.searchFn(
            query,
            this.currentController.signal
          );
          resolve(result);
        } catch (error) {
          if (error.name !== 'AbortError') {
            reject(error);
          }
        }
      }, this.debounceMs);
    });
  }

  cancel() {
    if (this.debounceTimer) {
      clearTimeout(this.debounceTimer);
    }
    if (this.currentController) {
      this.currentController.abort('Search cancelled');
    }
  }
}

// Usage
const search = new SearchController(async (query, signal) => {
  const response = await fetch(`/api/search?q=${query}`, { signal });
  return response.json();
});

// Each keystroke starts a new search, cancelling previous
input.addEventListener('input', async (e) => {
  try {
    const results = await search.search(e.target.value);
    displayResults(results);
  } catch (error) {
    console.error(error);
  }
});

Parallel Requests with Cancellation

async function fetchAllWithCancel(urls) {
  const controller = new AbortController();

  const promises = urls.map((url) =>
    fetch(url, { signal: controller.signal }).then((r) => r.json())
  );

  return {
    promise: Promise.all(promises),
    cancel: () => controller.abort(),
  };
}

// Usage
const { promise, cancel } = fetchAllWithCancel([
  '/api/users',
  '/api/posts',
  '/api/comments',
]);

// Cancel all if any single request is no longer needed
cancelButton.onclick = cancel;

try {
  const [users, posts, comments] = await promise;
} catch (error) {
  if (error.name === 'AbortError') {
    console.log('All requests cancelled');
  }
}

Race Pattern with Cancellation

async function raceWithCancel(promises) {
  const controller = new AbortController();

  const wrappedPromises = promises.map(({ url, ...options }) =>
    fetch(url, { ...options, signal: controller.signal }).then((r) => r.json())
  );

  try {
    const winner = await Promise.race(wrappedPromises);
    // Cancel losing requests
    controller.abort('Race completed');
    return winner;
  } catch (error) {
    controller.abort('Race failed');
    throw error;
  }
}

8. Error Handling Best Practices

Distinguishing Abort Errors

async function safeFetch(url, options = {}) {
  try {
    const response = await fetch(url, options);

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

    return await response.json();
  } catch (error) {
    if (error.name === 'AbortError') {
      // Request was cancelled - usually not an error
      return null;
    }

    if (error.name === 'TimeoutError') {
      // Request timed out
      throw new Error('Request timed out. Please try again.');
    }

    // Network or other error
    throw error;
  }
}

Creating Typed Abort Errors

class TimeoutAbortError extends DOMException {
    constructor(timeout) {
        super(`Operation timed out after ${timeout}ms`, 'TimeoutError');
        this.timeout = timeout;
    }
}

class UserCancelError extends DOMException {
    constructor(reason = 'User cancelled') {
        super(reason, 'AbortError');
        this.isUserCancel = true;
    }
}

// Usage
controller.abort(new UserCancelError());

// Handling
catch (error) {
    if (error instanceof UserCancelError) {
        // User explicitly cancelled
    } else if (error.name === 'TimeoutError') {
        // Timeout occurred
    }
}

Summary

Key Concepts

ConceptDescription
AbortControllerCreates a controller with signal and abort() method
AbortSignalPassed to async operations, triggers on abort
AbortSignal.timeout()Creates auto-aborting signal after timeout
AbortSignal.any()Combines multiple signals
signal.abortedBoolean indicating if aborted
signal.reasonThe reason passed to abort()

Best Practices

  1. Always handle AbortError - Don't treat cancellation as an error
  2. Clean up resources - Cancel pending operations on component unmount
  3. Use timeout - Prevent requests from hanging indefinitely
  4. Provide cancel functions - Let users cancel long operations
  5. Chain signals - Use combined signals for complex scenarios

Next Steps

  • Practice with the exercises in exercises.js
  • Review real-world examples in examples.js
  • Explore combining with other async patterns

Resources

.7 AbortController Cancellation - JavaScript Tutorial | DeepML