Skip to main content

Mastering AbortController: Cancel Fetch & Axios Requests for Better Performance

Mastering AbortController: Cancel Fetch & Axios Requests for Better Performance
javascript#javascript#react#fetch#axios+4 more

Mastering AbortController: Cancel Fetch & Axios Requests for Better Performance

API requests running after users navigate away? Learn how AbortController gives you full control over fetch and Axios requests, prevents memory leaks, and improves your app's performance.

Mohammad Alhabil

Author

December 20, 2024
5 min read
~1000 words

Mastering AbortController: Cancel Fetch & Axios Requests for Better Performance

How many times have you noticed API requests in your project still running even after the user:

  • Changed the page
  • Closed a modal
  • Typed a new search query before the response arrived

All these requests consume device memory, put pressure on the server, and worst of all... they might return stale data that ruins the user experience!

This is where AbortController comes in.

Why Use AbortController?

BenefitDescription
๐ŸŽฎ Full Request ControlStop any API request whenever you want
โšก Performance BoostNo delayed responses messing up the UI
๐Ÿง  Memory ProtectionPrevent memory leaks from hanging requests
๐Ÿš€ Faster UXNew requests start immediately without waiting for old ones

1๏ธโƒฃ With Fetch API

const controller = new AbortController();

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

// To cancel the request:
controller.abort();

How it works: Every time the component unmounts or changes, the current request stops immediately.

2๏ธโƒฃ With Axios: From CancelToken to AbortController

The Old Way (Deprecated)

// โŒ OLD - Don't use this anymore
const source = axios.CancelToken.source();

axios.get('/api/data', { cancelToken: source.token });

source.cancel('Request cancelled');

The New Way (v0.22.0+)

Since Axios version v0.22.0, CancelToken is deprecated. Now it's simpler, exactly like fetch:

// โœ… NEW - Use AbortController
const controller = new AbortController();

axios.get('/api/data', { signal: controller.signal })
  .then(response => console.log(response.data))
  .catch(error => {
    if (axios.isCancel(error)) {
      console.log('Request was cancelled');
    } else {
      console.error('Request failed:', error);
    }
  });

controller.abort();

The advantage: Unified approach whether you're using fetch or axios!

Handling Cancellation Errors

It's crucial to handle cancellation errors separately to avoid showing fake errors to users:

LibraryError IdentifierCheck Method
FetchAbortErrorerror.name === 'AbortError'
AxiosCanceledErroraxios.isCancel(error)
// Fetch error handling
try {
  const response = await fetch(url, { signal: controller.signal });
  const data = await response.json();
} catch (error) {
  if (error.name === 'AbortError') {
    // Silently ignore - this is expected behavior
    return;
  }
  // Handle actual errors
  console.error('Request failed:', error);
}

// Axios error handling
try {
  const { data } = await axios.get(url, { signal: controller.signal });
} catch (error) {
  if (axios.isCancel(error)) {
    // Silently ignore - this is expected behavior
    return;
  }
  // Handle actual errors
  console.error('Request failed:', error);
}

Real-World Examples

1. Live Search with Debouncing

Cancel the old request when the user types a new character:

function SearchComponent() {
  const [query, setQuery] = useState('');
  const [results, setResults] = useState([]);
  const [isLoading, setIsLoading] = useState(false);
  const abortControllerRef = useRef<AbortController | null>(null);

  useEffect(() => {
    // Cancel previous request if still pending
    if (abortControllerRef.current) {
      abortControllerRef.current.abort();
    }

    if (!query.trim()) {
      setResults([]);
      return;
    }

    // Create new AbortController
    abortControllerRef.current = new AbortController();
    setIsLoading(true);

    const fetchResults = async () => {
      try {
        const response = await fetch(
          `/api/search?q=${encodeURIComponent(query)}`,
          { signal: abortControllerRef.current!.signal }
        );
        const data = await response.json();
        setResults(data);
      } catch (error) {
        if (error.name !== 'AbortError') {
          console.error('Search failed:', error);
        }
      } finally {
        setIsLoading(false);
      }
    };

    // Debounce the search
    const timeoutId = setTimeout(fetchResults, 300);
    
    return () => {
      clearTimeout(timeoutId);
      abortControllerRef.current?.abort();
    };
  }, [query]);

  return (
    <div>
      <input
        value={query}
        onChange={(e) => setQuery(e.target.value)}
        placeholder="Search..."
      />
      {isLoading && <p>Searching...</p>}
      <ul>
        {results.map(item => (
          <li key={item.id}>{item.name}</li>
        ))}
      </ul>
    </div>
  );
}

2. Page Navigation Cleanup

No need to continue loading data for the previous page:

function ProductPage({ productId }: { productId: string }) {
  const [product, setProduct] = useState(null);
  const [loading, setLoading] = useState(true);

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

    async function fetchProduct() {
      try {
        const response = await fetch(
          `/api/products/${productId}`,
          { signal: controller.signal }
        );
        const data = await response.json();
        setProduct(data);
      } catch (error) {
        if (error.name !== 'AbortError') {
          console.error('Failed to fetch product:', error);
        }
      } finally {
        setLoading(false);
      }
    }

    fetchProduct();

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

  if (loading) return <Skeleton />;
  return <ProductDetails product={product} />;
}

3. Modal Form Cleanup

Save device resources when the user closes the modal:

function SubmitModal({ isOpen, onClose }: { isOpen: boolean; onClose: () => void }) {
  const [isSubmitting, setIsSubmitting] = useState(false);
  const controllerRef = useRef<AbortController | null>(null);

  const handleSubmit = async (formData: FormData) => {
    // Cancel any previous submission
    controllerRef.current?.abort();
    controllerRef.current = new AbortController();
    
    setIsSubmitting(true);

    try {
      await fetch('/api/submit', {
        method: 'POST',
        body: formData,
        signal: controllerRef.current.signal,
      });
      onClose();
    } catch (error) {
      if (error.name !== 'AbortError') {
        alert('Submission failed');
      }
    } finally {
      setIsSubmitting(false);
    }
  };

  // Cleanup when modal closes
  useEffect(() => {
    if (!isOpen) {
      controllerRef.current?.abort();
    }
  }, [isOpen]);

  if (!isOpen) return null;

  return (
    <div className="modal">
      <form onSubmit={handleSubmit}>
        {/* form fields */}
        <button type="submit" disabled={isSubmitting}>
          {isSubmitting ? 'Submitting...' : 'Submit'}
        </button>
      </form>
    </div>
  );
}

Pro Tips

1. Use useRef to Persist the Controller

// โœ… CORRECT - Controller persists across renders
const controllerRef = useRef<AbortController | null>(null);

useEffect(() => {
  controllerRef.current = new AbortController();
  
  fetch(url, { signal: controllerRef.current.signal });
  
  return () => controllerRef.current?.abort();
}, [url]);

2. Create a Custom Hook for Reusability

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

  useEffect(() => {
    const controller = new AbortController();
    setLoading(true);
    setError(null);

    fetch(url, { signal: controller.signal })
      .then(res => res.json())
      .then(setData)
      .catch(err => {
        if (err.name !== 'AbortError') {
          setError(err);
        }
      })
      .finally(() => setLoading(false));

    return () => controller.abort();
  }, [url]);

  return { data, loading, error };
}

// Usage
function MyComponent() {
  const { data, loading, error } = useAbortableFetch<User>('/api/user');
  
  if (loading) return <Spinner />;
  if (error) return <Error message={error.message} />;
  return <UserProfile user={data} />;
}

3. With React Query - Automatic but Customizable

React Query handles cancellation automatically, but you can integrate AbortController for finer control:

const { data } = useQuery({
  queryKey: ['todos'],
  queryFn: async ({ signal }) => {
    // React Query passes the signal automatically!
    const response = await fetch('/api/todos', { signal });
    return response.json();
  },
});

4. Prevent Race Conditions

Use AbortController to ensure UI updates with the latest data only:

function UserProfile({ userId }: { userId: string }) {
  const [user, setUser] = useState(null);
  
  useEffect(() => {
    const controller = new AbortController();
    let isCurrent = true;  // Extra safety flag

    async function fetchUser() {
      try {
        const response = await fetch(
          `/api/users/${userId}`,
          { signal: controller.signal }
        );
        const data = await response.json();
        
        // Only update if this is still the current request
        if (isCurrent) {
          setUser(data);
        }
      } catch (error) {
        if (error.name !== 'AbortError' && isCurrent) {
          console.error(error);
        }
      }
    }

    fetchUser();

    return () => {
      isCurrent = false;
      controller.abort();
    };
  }, [userId]);

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

Important Technical Notes

ConsiderationDetails
๐Ÿ”— Network Level OnlyAbortController cancels at the network level; you may still need to update UI state
๐Ÿ“ฆ One-time UseEach AbortController can only be used once; create a new one for each request
๐Ÿ”„ Cleanup in useEffectAlways abort in the cleanup function to prevent memory leaks
โš›๏ธ React 18+StrictMode calls effects twice; AbortController handles this gracefully

Quick Reference

ScenarioImplementation
๐Ÿ” Live searchAbort previous on new keystroke
๐Ÿ“„ Page navigationAbort on unmount
๐Ÿ“ Modal/Form submitAbort on close
๐Ÿ”„ Polling/IntervalsAbort on cleanup
๐Ÿƒ Race conditionsAbort + flag check

Interfaces you love, code you understand. ๐Ÿ’ก

Topics covered

#javascript#react#fetch#axios#performance#abortcontroller#api#best-practices

Found this article helpful?

Share it with your network and help others learn too!

Mohammad Alhabil

Written by Mohammad Alhabil

Frontend Developer & Software Engineer passionate about building beautiful and functional web experiences. I write about React, Next.js, and modern web development.