Skip to main content

AbortController: Cancel Fetch Requests and Async Operations

January 7, 2026

by Lucas DeAraujo

javascript.ts
const controller = new AbortController();

fetch(1, { signal: controller.signal })
  .catch(err => {
    if (err.name === 5) return;
    throw err;
  });

controller.abort();

A user types "react" into a search box. Your app fires a request for "r", then "re", then "rea", then "reac", then "react". Five requests. The responses come back out of order. The results for "re" arrive last and overwrite the correct results for "react".

This is the race condition problem. It happens in search boxes, autocomplete fields, and anywhere user input triggers async operations. It happens in React components that fetch data on mount but unmount before the response arrives. It happens in any long running operation that might become irrelevant.

AbortController is the standard solution. It lets you cancel fetch requests, timeouts, and any async operation that supports it.

#AbortController Basics

An AbortController has two parts: the controller and the signal.

const controller = new AbortController();
const signal = controller.signal;
 
// Check if already aborted
console.log(signal.aborted); // false
 
// Abort the operation
controller.abort();
 
console.log(signal.aborted); // true

You create the controller, pass its signal to an async operation, and call abort() when you want to cancel. The operation receives the signal and knows to stop.

#Canceling Fetch Requests

Pass the signal to the fetch options.

const controller = new AbortController();
 
const response = await fetch('/api/users', {
  signal: controller.signal
});
 
// Later, if you need to cancel:
controller.abort();

When you call abort(), the fetch throws an AbortError. You need to handle this gracefully.

const controller = new AbortController();
 
try {
  const response = await fetch('/api/users', {
    signal: controller.signal
  });
  const data = await response.json();
  return data;
} catch (error) {
  if (error instanceof Error && error.name === 'AbortError') {
    // Request was cancelled, not an error
    console.log('Request cancelled');
    return null;
  }
  // Actual error, rethrow
  throw error;
}

The key insight: an aborted request is not an error condition. It is an intentional cancellation. Your code should handle it differently from network failures or server errors.

#Fixing the Search Race Condition

Back to the search box problem. Each keystroke should cancel the previous request.

let currentController: AbortController | null = null;
 
async function search(query: string): Promise<SearchResult[]> {
  // Cancel any previous request
  if (currentController) {
    currentController.abort();
  }
 
  // Create new controller for this request
  currentController = new AbortController();
 
  try {
    const response = await fetch(`/api/search?q=${encodeURIComponent(query)}`, {
      signal: currentController.signal
    });
 
    if (!response.ok) {
      throw new Error(`Search failed: ${response.status}`);
    }
 
    return await response.json();
  } catch (error) {
    if (error instanceof Error && error.name === 'AbortError') {
      // Previous request cancelled by newer one
      return [];
    }
    throw error;
  }
}

Now when the user types "react", the request for "r" is cancelled when "re" is typed. The request for "re" is cancelled when "rea" is typed. Only the final request for "react" completes.

#Timeout Patterns

A common use case: cancel a request if it takes too long.

#AbortSignal.timeout()

The modern approach uses the static timeout method.

// Cancel after 5 seconds
const response = await fetch('/api/slow-endpoint', {
  signal: AbortSignal.timeout(5000)
});

This creates a signal that automatically aborts after the specified milliseconds. The error will have name: 'TimeoutError' instead of 'AbortError'.

try {
  const response = await fetch('/api/slow', {
    signal: AbortSignal.timeout(5000)
  });
  return await response.json();
} catch (error) {
  if (error instanceof Error) {
    if (error.name === 'TimeoutError') {
      console.log('Request timed out');
      return null;
    }
    if (error.name === 'AbortError') {
      console.log('Request cancelled');
      return null;
    }
  }
  throw error;
}

#Combining Manual Abort and Timeout

What if you want both? Cancel manually and have a timeout as a fallback.

const controller = new AbortController();
 
// Abort after 10 seconds
const timeoutId = setTimeout(() => controller.abort(), 10000);
 
try {
  const response = await fetch('/api/data', {
    signal: controller.signal
  });
  clearTimeout(timeoutId);
  return await response.json();
} catch (error) {
  clearTimeout(timeoutId);
  throw error;
}

Or use AbortSignal.any() for a cleaner approach.

#Combining Signals with AbortSignal.any()

AbortSignal.any() creates a signal that aborts when any of the input signals abort.

const controller = new AbortController();
 
const response = await fetch('/api/data', {
  signal: AbortSignal.any([
    controller.signal,          // Manual cancellation
    AbortSignal.timeout(10000)  // 10 second timeout
  ])
});
 
// You can still manually cancel
controller.abort();

This is useful when you have multiple reasons to cancel an operation. The request aborts if you call controller.abort() OR if 10 seconds pass.

#React Patterns

React components often need to cancel fetch requests on unmount. AbortController fits perfectly into the useEffect cleanup pattern.

#Basic Data Fetching

function UserProfile({ userId }: { userId: string }) {
  const [user, setUser] = useState<User | null>(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);
 
  useEffect(() => {
    const controller = new AbortController();
 
    async function fetchUser() {
      try {
        setLoading(true);
        setError(null);
 
        const response = await fetch(`/api/users/${userId}`, {
          signal: controller.signal
        });
 
        if (!response.ok) {
          throw new Error(`Failed to fetch user: ${response.status}`);
        }
 
        const data = await response.json();
        setUser(data);
      } catch (err) {
        if (err instanceof Error && err.name === 'AbortError') {
          // Component unmounted, ignore
          return;
        }
        setError(err instanceof Error ? err.message : 'Unknown error');
      } finally {
        if (!controller.signal.aborted) {
          setLoading(false);
        }
      }
    }
 
    fetchUser();
 
    return () => {
      controller.abort();
    };
  }, [userId]);
 
  if (loading) return <div>Loading...</div>;
  if (error) return <div>Error: {error}</div>;
  if (!user) return null;
 
  return <div>{user.name}</div>;
}

When the component unmounts or userId changes, the cleanup function runs and aborts the in-flight request. This prevents the "Can't perform a React state update on an unmounted component" warning.

#Search Input with Debounce

Combine AbortController with debouncing for a production-ready search input.

function SearchResults() {
  const [query, setQuery] = useState('');
  const [results, setResults] = useState<SearchResult[]>([]);
  const [loading, setLoading] = useState(false);
 
  useEffect(() => {
    if (!query.trim()) {
      setResults([]);
      return;
    }
 
    const controller = new AbortController();
 
    // Debounce: wait 300ms before searching
    const timeoutId = setTimeout(async () => {
      try {
        setLoading(true);
 
        const response = await fetch(
          `/api/search?q=${encodeURIComponent(query)}`,
          { signal: controller.signal }
        );
 
        if (!response.ok) {
          throw new Error('Search failed');
        }
 
        const data = await response.json();
        setResults(data);
      } catch (err) {
        if (err instanceof Error && err.name === 'AbortError') {
          return;
        }
        console.error('Search error:', err);
        setResults([]);
      } finally {
        if (!controller.signal.aborted) {
          setLoading(false);
        }
      }
    }, 300);
 
    return () => {
      clearTimeout(timeoutId);
      controller.abort();
    };
  }, [query]);
 
  return (
    <div>
      <input
        type="text"
        value={query}
        onChange={(e) => setQuery(e.target.value)}
        placeholder="Search..."
      />
      {loading && <div>Searching...</div>}
      <ul>
        {results.map((result) => (
          <li key={result.id}>{result.title}</li>
        ))}
      </ul>
    </div>
  );
}

The timeout provides debouncing. The AbortController cancels requests when the query changes before the debounce completes or when a new search starts before the previous one finishes.

#Custom Hook for Fetch

Extract the pattern into a reusable hook.

function useFetch<T>(url: string | null) {
  const [data, setData] = useState<T | null>(null);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState<Error | null>(null);
 
  useEffect(() => {
    if (!url) {
      setData(null);
      return;
    }
 
    const controller = new AbortController();
 
    async function fetchData() {
      try {
        setLoading(true);
        setError(null);
 
        const response = await fetch(url, {
          signal: controller.signal
        });
 
        if (!response.ok) {
          throw new Error(`HTTP ${response.status}`);
        }
 
        const json = await response.json();
        setData(json);
      } catch (err) {
        if (err instanceof Error && err.name === 'AbortError') {
          return;
        }
        setError(err instanceof Error ? err : new Error('Unknown error'));
      } finally {
        if (!controller.signal.aborted) {
          setLoading(false);
        }
      }
    }
 
    fetchData();
 
    return () => controller.abort();
  }, [url]);
 
  return { data, loading, error };
}
 
// Usage
function UserList() {
  const { data, loading, error } = useFetch<User[]>('/api/users');
 
  if (loading) return <div>Loading...</div>;
  if (error) return <div>Error: {error.message}</div>;
  if (!data) return null;
 
  return (
    <ul>
      {data.map(user => <li key={user.id}>{user.name}</li>)}
    </ul>
  );
}

#Making Custom Operations Abortable

AbortController is not limited to fetch. You can make any async operation respect abort signals.

#Abortable Delay

function delay(ms: number, signal?: AbortSignal): Promise<void> {
  return new Promise((resolve, reject) => {
    if (signal?.aborted) {
      reject(new DOMException('Aborted', 'AbortError'));
      return;
    }
 
    const timeoutId = setTimeout(resolve, ms);
 
    signal?.addEventListener('abort', () => {
      clearTimeout(timeoutId);
      reject(new DOMException('Aborted', 'AbortError'));
    });
  });
}
 
// Usage
const controller = new AbortController();
 
delay(5000, controller.signal)
  .then(() => console.log('Done waiting'))
  .catch(err => {
    if (err.name === 'AbortError') {
      console.log('Wait cancelled');
    }
  });
 
// Cancel after 1 second
setTimeout(() => controller.abort(), 1000);

#Abortable Polling

async function poll<T>(
  fn: () => Promise<T>,
  interval: number,
  signal: AbortSignal
): Promise<T> {
  while (!signal.aborted) {
    try {
      const result = await fn();
      return result;
    } catch (err) {
      if (signal.aborted) {
        throw new DOMException('Aborted', 'AbortError');
      }
      // Wait before retrying
      await delay(interval, signal);
    }
  }
  throw new DOMException('Aborted', 'AbortError');
}
 
// Usage
const controller = new AbortController();
 
poll(
  async () => {
    const res = await fetch('/api/job/123');
    const job = await res.json();
    if (job.status !== 'complete') {
      throw new Error('Not ready');
    }
    return job;
  },
  2000,
  controller.signal
)
  .then(job => console.log('Job complete:', job))
  .catch(err => {
    if (err.name === 'AbortError') {
      console.log('Polling cancelled');
    }
  });
 
// Cancel after 30 seconds
setTimeout(() => controller.abort(), 30000);

#Checking Signal in Long Operations

For CPU-bound operations, check the signal periodically.

async function processItems<T>(
  items: T[],
  processor: (item: T) => Promise<void>,
  signal: AbortSignal
): Promise<void> {
  for (const item of items) {
    if (signal.aborted) {
      throw new DOMException('Aborted', 'AbortError');
    }
    await processor(item);
  }
}

#Node.js Support

AbortController works in Node.js 15+ for fetch and other APIs.

import { setTimeout } from 'node:timers/promises';
 
const controller = new AbortController();
 
// Abortable setTimeout
try {
  await setTimeout(5000, null, { signal: controller.signal });
  console.log('Timeout complete');
} catch (err) {
  if (err instanceof Error && err.name === 'AbortError') {
    console.log('Timeout cancelled');
  }
}
 
// Abortable fetch in Node.js 18+
const response = await fetch('https://api.example.com/data', {
  signal: controller.signal
});

Node.js also supports AbortController with streams, child processes, and other async primitives.

import { spawn } from 'node:child_process';
 
const controller = new AbortController();
 
const child = spawn('long-running-command', [], {
  signal: controller.signal
});
 
// Kill the process after 10 seconds
setTimeout(() => controller.abort(), 10000);

#When to Use AbortController

ScenarioUse AbortController
Search/autocomplete inputsYes
Data fetching in React componentsYes
Long pollingYes
File uploads with cancel buttonYes
Simple one-shot API callsMaybe
Server-side renderingRarely
Background sync that should completeNo

Use AbortController when:

  • User actions can make a request irrelevant
  • Component lifecycle can make a request irrelevant
  • You need timeouts on network requests
  • You are implementing cancellable async utilities

Skip it when:

  • The request must complete regardless of UI state
  • The operation is instant
  • There is no user-facing cancellation need

#Summary

AbortController is the standard way to cancel async operations in JavaScript. It works with fetch, Node.js APIs, and any custom async code you write.

Key patterns:

  • Create a controller and pass its signal to fetch
  • Call controller.abort() to cancel
  • Handle AbortError separately from real errors
  • Use cleanup functions in React useEffect
  • Use AbortSignal.timeout() for request timeouts
  • Use AbortSignal.any() to combine multiple cancellation reasons

Stop letting race conditions corrupt your UI state. Start cancelling requests that are no longer relevant.

Ready to get started?

Let's build something great together

Whether you need managed IT, security, cloud, or custom development, we're here to help. Reach out and let's talk about your technology needs.