Learning Objectives

  • Master AbortController and AbortSignal
  • Implement cancellable async operations
  • Handle cleanup in cancelled operations
  • Build cancellable API clients

Why Cancel Promises?

Cancel async operations when:

AbortController Basics

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

// Use with fetch
fetch('/api/data', { signal })
  .then(response => response.json())
  .then(data => console.log(data))
  .catch(error => {
    if (error.name === 'AbortError') {
      console.log('Request cancelled');
    } else {
      console.error('Request failed:', error);
    }
  });

// Cancel the request
controller.abort();

Cancellable Fetch

async function fetchWithCancel(url) {
  const controller = new AbortController();
  
  const fetchPromise = fetch(url, { signal: controller.signal })
    .then(response => response.json());
  
  // Return both promise and cancel function
  return {
    promise: fetchPromise,
    cancel: () => controller.abort()
  };
}

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

promise
  .then(data => console.log(data))
  .catch(error => {
    if (error.name === 'AbortError') {
      console.log('Cancelled');
    }
  });

// Cancel after 5 seconds
setTimeout(() => cancel(), 5000);

Cancellable Async Function

function makeCancellable(asyncFn) {
  const controller = new AbortController();
  
  const promise = asyncFn(controller.signal);
  
  return {
    promise,
    cancel: () => controller.abort()
  };
}

// Usage
async function longRunningTask(signal) {
  for (let i = 0; i < 100; i++) {
    // Check if cancelled
    if (signal.aborted) {
      throw new Error('Operation cancelled');
    }
    
    await processItem(i);
  }
  
  return 'Complete';
}

const { promise, cancel } = makeCancellable(longRunningTask);

promise
  .then(result => console.log(result))
  .catch(error => console.error(error.message));

// Cancel after 2 seconds
setTimeout(() => cancel(), 2000);

Listening to Abort Events

async function cancellableOperation(signal) {
  // Listen for abort event
  signal.addEventListener('abort', () => {
    console.log('Operation cancelled, cleaning up...');
    cleanup();
  });
  
  try {
    const data = await fetch('/api/data', { signal });
    return await data.json();
  } catch (error) {
    if (error.name === 'AbortError') {
      console.log('Fetch was cancelled');
    }
    throw error;
  }
}

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

// Cancel
controller.abort();

Timeout with AbortController

function fetchWithTimeout(url, timeout = 5000) {
  const controller = new AbortController();
  
  const timeoutId = setTimeout(() => {
    controller.abort();
  }, timeout);
  
  return fetch(url, { signal: controller.signal })
    .then(response => {
      clearTimeout(timeoutId);
      return response.json();
    })
    .catch(error => {
      clearTimeout(timeoutId);
      if (error.name === 'AbortError') {
        throw new Error('Request timed out');
      }
      throw error;
    });
}

// Usage
try {
  const data = await fetchWithTimeout('/api/data', 3000);
  console.log(data);
} catch (error) {
  console.error(error.message);
}

Cancellable Promise Wrapper

class CancellablePromise {
  constructor(executor) {
    this.controller = new AbortController();
    this.signal = this.controller.signal;
    
    this.promise = new Promise((resolve, reject) => {
      this.signal.addEventListener('abort', () => {
        reject(new Error('Cancelled'));
      });
      
      executor(resolve, reject, this.signal);
    });
  }
  
  cancel() {
    this.controller.abort();
  }
  
  then(onFulfilled, onRejected) {
    return this.promise.then(onFulfilled, onRejected);
  }
  
  catch(onRejected) {
    return this.promise.catch(onRejected);
  }
}

// Usage
const cancellable = new CancellablePromise((resolve, reject, signal) => {
  const timeoutId = setTimeout(() => {
    if (!signal.aborted) {
      resolve('Done');
    }
  }, 5000);
  
  signal.addEventListener('abort', () => {
    clearTimeout(timeoutId);
  });
});

cancellable
  .then(result => console.log(result))
  .catch(error => console.error(error.message));

// Cancel after 2 seconds
setTimeout(() => cancellable.cancel(), 2000);

Cancellable API Client

class CancellableAPIClient {
  constructor(baseURL) {
    this.baseURL = baseURL;
    this.pendingRequests = new Map();
  }
  
  async request(endpoint, options = {}) {
    const controller = new AbortController();
    const requestId = `${options.method || 'GET'}-${endpoint}`;
    
    // Cancel previous request to same endpoint
    if (this.pendingRequests.has(requestId)) {
      this.pendingRequests.get(requestId).abort();
    }
    
    this.pendingRequests.set(requestId, controller);
    
    try {
      const response = await fetch(`${this.baseURL}${endpoint}`, {
        ...options,
        signal: controller.signal
      });
      
      if (!response.ok) {
        throw new Error(`HTTP ${response.status}`);
      }
      
      const data = await response.json();
      this.pendingRequests.delete(requestId);
      return data;
      
    } catch (error) {
      this.pendingRequests.delete(requestId);
      
      if (error.name === 'AbortError') {
        throw new Error('Request cancelled');
      }
      throw error;
    }
  }
  
  cancelAll() {
    this.pendingRequests.forEach(controller => controller.abort());
    this.pendingRequests.clear();
  }
  
  cancel(endpoint, method = 'GET') {
    const requestId = `${method}-${endpoint}`;
    const controller = this.pendingRequests.get(requestId);
    
    if (controller) {
      controller.abort();
      this.pendingRequests.delete(requestId);
    }
  }
}

// Usage
const api = new CancellableAPIClient('https://api.example.com');

// Start request
api.request('/users')
  .then(users => console.log(users))
  .catch(error => console.error(error.message));

// Cancel specific request
api.cancel('/users');

// Or cancel all
api.cancelAll();

React Hook Example

function useCancellableFetch(url) {
  const [data, setData] = React.useState(null);
  const [loading, setLoading] = React.useState(true);
  const [error, setError] = React.useState(null);
  
  React.useEffect(() => {
    const controller = new AbortController();
    
    fetch(url, { signal: controller.signal })
      .then(response => response.json())
      .then(data => {
        setData(data);
        setLoading(false);
      })
      .catch(error => {
        if (error.name !== 'AbortError') {
          setError(error);
          setLoading(false);
        }
      });
    
    // Cleanup: cancel on unmount
    return () => controller.abort();
  }, [url]);
  
  return { data, loading, error };
}

// Usage in component
function UserProfile({ userId }) {
  const { data, loading, error } = useCancellableFetch(
    `/api/users/${userId}`
  );
  
  if (loading) return 
Loading...
; if (error) return
Error: {error.message}
; return
{data.name}
; }

Debounced Search with Cancellation

class DebouncedSearch {
  constructor(searchFn, delay = 300) {
    this.searchFn = searchFn;
    this.delay = delay;
    this.timeoutId = null;
    this.controller = null;
  }
  
  search(query) {
    // Cancel previous search
    if (this.controller) {
      this.controller.abort();
    }
    
    // Clear previous timeout
    clearTimeout(this.timeoutId);
    
    return new Promise((resolve, reject) => {
      this.timeoutId = setTimeout(async () => {
        this.controller = new AbortController();
        
        try {
          const results = await this.searchFn(
            query,
            this.controller.signal
          );
          resolve(results);
        } catch (error) {
          if (error.name !== 'AbortError') {
            reject(error);
          }
        }
      }, this.delay);
    });
  }
  
  cancel() {
    clearTimeout(this.timeoutId);
    if (this.controller) {
      this.controller.abort();
    }
  }
}

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

// User typing
input.addEventListener('input', async (e) => {
  try {
    const results = await search.search(e.target.value);
    displayResults(results);
  } catch (error) {
    console.error(error);
  }
});

Multiple Signals

function combineSignals(...signals) {
  const controller = new AbortController();
  
  for (const signal of signals) {
    if (signal.aborted) {
      controller.abort();
      break;
    }
    
    signal.addEventListener('abort', () => controller.abort());
  }
  
  return controller.signal;
}

// Usage
const userController = new AbortController();
const timeoutController = new AbortController();

setTimeout(() => timeoutController.abort(), 5000);

const combinedSignal = combineSignals(
  userController.signal,
  timeoutController.signal
);

fetch('/api/data', { signal: combinedSignal })
  .then(response => response.json())
  .catch(error => {
    if (error.name === 'AbortError') {
      console.log('Cancelled by user or timeout');
    }
  });

Best Practices

  1. Always check signal.aborted in long operations
  2. Clean up resources on cancellation
  3. Handle AbortError separately from other errors
  4. Cancel on component unmount in frameworks
  5. Use timeouts with AbortController
  6. Cancel superseded requests (e.g., search)
  7. Clear timeouts when cancelling

Key Takeaways

  • ✅ Use AbortController for cancellation
  • ✅ Pass signal to fetch and async functions
  • ✅ Check signal.aborted in long operations
  • ✅ Handle AbortError separately
  • ✅ Clean up resources on cancellation
  • ✅ Cancel on component unmount

Next Steps

Next, we'll learn about Promise memoization and caching strategies!