Skip to content
ExponaBLOG
JAVASCRIPT & TYPESCRIPT · DAY 073 / 100

Cancel requests with AbortController: a practical guide

Stop work nobody is waiting for.

Expona Admin8 min read

Cancel requests with AbortController

You've probably shipped a feature where a user types in a search box, the app fires off a request, then they immediately clear it and type something else—but the old request still completes and overwrites the fresh data. Or worse, a component unmounts while a fetch is still hanging, causing memory leaks and silent errors. The solution is learning to cancel requests with AbortController, a web standard that puts you in control of network lifecycle.

Why cancellation matters

In production, I've seen this play out repeatedly. A user navigates away from a modal that fetches a list of transactions. The request takes 8 seconds on a slow connection. The component unmounts after 2 seconds, but the fetch still completes—and now you're updating state on an unmounted component, React throws a warning, and you're reaching for memory leak workarounds that shouldn't exist.

Cancellation is especially critical in these scenarios:

  • Search inputs where each keystroke triggers a new request
  • Navigation away from a page with pending operations
  • User-initiated retry or reload actions
  • Enforcing request timeouts on slow networks
  • Replacing one request with another (pagination, filters)

Without cancellation, you're burning bandwidth, battery, and user patience. With it, you're being a responsible developer.

Pass signal to fetch

The foundation of AbortController is simple: create a controller, grab its signal, pass it to fetch, and call abort() when you want to stop.

typescript
const controller = new AbortController();

const res = await fetch('/api/users', {
  signal: controller.signal,
});

// Later, if needed:
controller.abort();

When you call abort(), the fetch immediately rejects with an AbortError. The HTTP request itself is cancelled at the network level—no more data flows, no more processing on either end.

In a real search component, here's how it looks:

typescript
import { useState, useRef } from 'react';

export function UserSearch() {
  const [results, setResults] = useState([]);
  const [loading, setLoading] = useState(false);
  const controllerRef = useRef(null);

  const handleSearch = async (query: string) => {
    // Cancel the previous request
    if (controllerRef.current) {
      controllerRef.current.abort();
    }

    // Create a fresh controller for this request
    controllerRef.current = new AbortController();
    setLoading(true);

    try {
      const res = await fetch(`/api/users?q=${query}`, {
        signal: controllerRef.current.signal,
      });
      const data = await res.json();
      setResults(data);
    } catch (err) {
      // We'll handle AbortError separately below
      console.error('Search failed:', err);
    } finally {
      setLoading(false);
    }
  };

  return (
    <div>
       handleSearch(e.target.value)}
      />
      {loading && <p>Searching...</p>}
      <ul>
        {results.map((user) => (
          <li>{user.name}</li>
        ))}
      </ul>
    </div>
  );
}

I store the controller in a useRef so it persists across renders. Each time the user types, I abort the old request and start a new one. Only the latest request's results get rendered.

Cleanup on unmount is where most developers miss the boat. If your component unmounts, you must abort in-flight requests, or React will warn you about state updates.

typescript
import { useEffect, useRef, useState } from 'react';

export function PostDetail({ postId }: { postId: string }) {
  const [post, setPost] = useState(null);
  const controllerRef = useRef(null);

  useEffect(() => {
    const controller = new AbortController();
    controllerRef.current = controller;

    const fetchPost = async () => {
      try {
        const res = await fetch(`/api/posts/${postId}`, {
          signal: controller.signal,
        });
        const data = await res.json();
        setPost(data);
      } catch (err) {
        if (err instanceof Error && err.name === 'AbortError') {
          console.log('Request was cancelled');
          return;
        }
        console.error('Failed to fetch post:', err);
      }
    };

    fetchPost();

    // Cleanup: abort if component unmounts
    return () => {
      controller.abort();
    };
  }, [postId]);

  return <div>{post ? post.title : 'Loading...'}</div>;
}

The cleanup function runs when the component unmounts or when the dependency (postId) changes. By aborting there, you're guaranteed no orphaned requests.

You can also abort when a user explicitly wants a new search—like I showed in the search example. The pattern is always the same: abort the old controller, create a new one, and fetch again.

AbortSignal.timeout for deadlines

Sometimes you don't want to manually manage abort logic. You just want "if this takes longer than X milliseconds, kill it." That's exactly what AbortSignal.timeout does.

typescript
const res = await fetch('/api/heavy-computation', {
  signal: AbortSignal.timeout(5000), // 5 second timeout
});

This is cleaner than setting up a separate setTimeout and calling abort(). The signal automatically rejects after 5 seconds with an AbortError.

On slow networks, this is lifesaving. I've used it in ride-hailing and travel platforms where a 10-second request hanging is worse than a 5-second timeout and retry. Here's a practical wrapper:

typescript
async function fetchWithTimeout(
  url: string,
  options: RequestInit & { timeout?: number } = {}
) {
  const { timeout = 8000, ...fetchOptions } = options;

  try {
    const res = await fetch(url, {
      ...fetchOptions,
      signal: AbortSignal.timeout(timeout),
    });
    return res;
  } catch (err) {
    if (err instanceof Error && err.name === 'AbortError') {
      throw new Error(`Request timeout after ${timeout}ms`);
    }
    throw err;
  }
}

// Usage
const data = await fetchWithTimeout('/api/bookings', {
  timeout: 3000,
}).then((r) => r.json());

You can also compose timeouts with manual cancellation using AbortSignal.any():

typescript
const manualController = new AbortController();
const timeoutSignal = AbortSignal.timeout(5000);

const res = await fetch('/api/data', {
  signal: AbortSignal.any([manualController.signal, timeoutSignal]),
});

// Request cancels if either the timeout fires OR you call manualController.abort()

This gives you the best of both worlds: automatic timeout safety plus user-initiated cancellation.

Handle AbortError separately

When you abort a request, it throws an error. But it's not a network error, not a 500, not your fault. It's intentional. You should handle it differently from genuine failures.

typescript
try {
  const res = await fetch('/api/users', {
    signal: controller.signal,
  });
  const data = await res.json();
  setUsers(data);
} catch (err) {
  if (err instanceof Error && err.name === 'AbortError') {
    // This was intentional cancellation. Don't alert the user.
    console.log('Request was cancelled (expected)');
    return;
  }

  // This is a real error. Show it to the user.
  setError('Failed to load users. Please try again.');
}

I check err.name === 'AbortError' to distinguish cancellations from real failures. In the search example, I silence abort errors entirely—no spinner, no error message. The user knows they changed their search term, so cancellation is expected.

But for something like a form submission, you might log the abort but still show a generic "please try again" message, because the user didn't explicitly cancel—they might have navigated accidentally.

typescript
const submitForm = async (formData: FormData) => {
  const controller = new AbortController();
  
  try {
    const res = await fetch('/api/submit', {
      method: 'POST',
      body: formData,
      signal: controller.signal,
    });
    setSuccess(true);
  } catch (err) {
    if (err instanceof Error && err.name === 'AbortError') {
      // User might have navigated away. Be silent.
      return;
    }
    setError('Submission failed. Try again.');
  }
};

// In cleanup or on navigation:
return () => controller.abort();

Common mistakes

Forgetting to handle AbortError. Your catch block treats all errors the same, so you show an error message when the request was just cancelled. Distinguish AbortError explicitly.

Creating a new controller on every render. If you call new AbortController() during render instead of in useRef or useCallback, you'll abort perfectly good requests. Store the controller in a ref or callback.

Not aborting on component unmount. Skip the cleanup function and you'll update state on unmounted components. Always abort in your cleanup return.

Mixing AbortController with timeout libraries. If you use AbortSignal.timeout(), don't also set a setTimeout that calls abort(). You'll race them and risk confusing errors. Pick one approach per request.

Assuming abort() is instant. abort() cancels the request, but the promise still needs to settle. You can't rely on synchronous state after calling abort(). Always await and catch the error.

Key takeaways

  • Create an AbortController, pass its signal to fetch, call abort() to cancel—this stops network traffic and rejects the promise.
  • Store controllers in useRef so they persist across renders; abort in useEffect cleanup to prevent unmount state updates.
  • Use AbortSignal.timeout(ms) for automatic deadlines; combine multiple signals with AbortSignal.any() for flexible cancellation logic.
  • Always catch AbortError separately from network errors; cancelled requests are expected, not failures.
  • Search inputs and navigation-triggered unmounts are the two biggest wins—cancel old searches on new input, cancel pending requests on route change.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles