I had a search page that fired a fetch on every keystroke. Worked fine until someone typed fast. Then the requests piled up, responses came back out of order, and the results showed garbage from three queries ago. Classic race condition, and I caused it myself.

The fix is AbortController. It is not new, but most people either do not know it exists or forget to use it.

JavaScript code on screen
The thing I kept ignoring until it bit me.

The Problem

Every fetch() returns a promise. If you fire five in a row, they all run. There is no built-in cancellation. The browser keeps waiting for all of them, and whichever resolves last wins.

That means if you type "a", "ab", "abc" in quick succession, you might get results for "a" showing up after "abc" already returned. The user sees wrong data and thinks your app is broken.

The Basic Setup

Here is the simplest version:

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

fetch('/api/search?q=hello', { signal })
  .then(res => res.json())
  .then(data => console.log(data))
  .catch(err => {
    if (err.name === 'AbortError') {
      console.log('Request was cancelled');
    } else {
      console.error('Real error:', err);
    }
  });

// Cancel it
controller.abort();

The signal object is what connects the controller to the fetch. You pass it in the options, and when you call abort(), the fetch throws an AbortError. That is it. No magic.

Network connection cables
Where the requests go to die.

Using It in a Search Input

Here is the pattern I use for search-as-you-type:

let currentController = null;

async function search(query) {
  // Abort the previous request if it exists
  if (currentController) {
    currentController.abort();
  }

  // Create a new controller for this request
  currentController = new AbortController();

  try {
    const res = await fetch(`/api/search?q=${encodeURIComponent(query)}`, {
      signal: currentController.signal
    });
    const data = await res.json();
    renderResults(data);
  } catch (err) {
    if (err.name !== 'AbortError') {
      console.error('Search failed:', err);
    }
  }
}

// Debounce wrapper
let timeout = null;
input.addEventListener('input', (e) => {
  clearTimeout(timeout);
  timeout = setTimeout(() => search(e.target.value), 300);
});

Every new search aborts the old one. I also added a debounce because aborting five requests per second is wasteful even if it works. 300ms is the sweet spot for search inputs, at least for me.

Cancelling on Navigation

If a user navigates away while a request is in flight, you want to cancel it. Otherwise you get errors in the console from requests that do not matter anymore.

// Store controllers globally so you can clean up
const pendingRequests = new Set();

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

  pendingRequests.add(controller);

  return fetch(url, options).finally(() => {
    pendingRequests.delete(controller);
  });
}

// Cancel everything when leaving the page
window.addEventListener('beforeunload', () => {
  pendingRequests.forEach(c => c.abort());
  pendingRequests.clear();
});
Person typing on laptop
Where most of my race conditions start.

Timeouts With AbortSignal.timeout()

There is also AbortSignal.timeout() which creates a signal that aborts after a given time. Useful when you do not want to manage a controller yourself.

// Abort after 5 seconds
const res = await fetch('/api/slow-endpoint', {
  signal: AbortSignal.timeout(5000)
}).catch(err => {
  if (err.name === 'TimeoutError') {
    console.log('Request timed out');
  }
  throw err;
});

This is cleaner than setTimeout + abort for simple cases. But if you need to abort manually too, stick with AbortController.

Combining Signals

You can combine multiple signals with AbortSignal.any(). If any of them aborts, the fetch cancels.

const userController = new AbortController();

// Cancel if user clicks cancel OR if it takes more than 10s
const combined = AbortSignal.any([
  userController.signal,
  AbortSignal.timeout(10000)
]);

const res = await fetch('/api/data', { signal: combined });

I use this for file uploads. The user can cancel manually, but it also times out if the server is stuck.

Tech screen with code
The part where I actually tested this stuff.

Gotchas

AbortError is not a real error. It is expected behavior. Always check err.name === 'AbortError' before logging or showing an error to the user.

Aborting a fetch does not stop the server. The server still processes the request. You just stop waiting for the response. If you have expensive server-side work, you need your own cancellation logic on the backend.

Also, once you abort a signal, you cannot reuse it. You need a new controller for each request. I spent an hour debugging that once.

Conclusion

AbortController is not glamorous. It is one of those APIs you learn once and then sprinkle everywhere. If you are doing any kind of search-as-you-type, live updates, or anything where requests can become stale, use it. Your users will not notice, but your console will be quieter.

:)