← Back to list

AbortController doesn’t replace async/await — it completes what was missing

Why almost every fetch request you write in React is probably incomplete, and how to fix it with just a few lines of code.

Blense blog · 2026-07-10 04:51 · 0 claps · 5.7 min read
#react #web-development #programming #technology
Open on Medium ↗
Wiki topics: 💻 · Programming 🌐 · Web Development

image by Google Gemini

image by Google Gemini

AbortController doesn’t replace async/await — it completes what was missing

Why almost every fetch request you write in React is probably incomplete, and how to fix it with just a few lines of code.

There’s a common misconception among JavaScript developers: treating AbortController and async/await as competing alternatives, as if you had to pick one over the other. You don't. They solve different problems, operate at different layers, and in practice work best together.

This article explains the conceptual difference between the two, walks through the scenarios where the absence of cancellation causes real bugs — race conditions, memory leaks, wasted network resources — and ends with a ready-to-use pattern for React applications.

Two tools, two problems

Promises and async/await control flow. They exist so asynchronous code can be written in a linear, readable way, with structured error handling via try/catch and composition via Promise.all or Promise.race. They solved callback hell. But there's one thing they never set out to solve: once started, a Promise cannot be canceled. It will resolve or reject, and your code will react — even if nobody cares about the result anymore.

AbortController controls lifecycle. It’s an API with a single purpose: sending a cancellation signal to in-flight asynchronous operations. It doesn’t manage flow, doesn’t handle business errors, and doesn’t replace anything — it simply adds the capability that was missing.

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 canceled')
    } else {
      console.error('Actual error:', error)
    }
  })

// At some point in the future:
controller.abort()

Think of it as two layers: the first (async/await) manages the sequence of operations; the second (AbortController) manages their cancellation. Neither invades the other’s territory.

Where missing cancellation becomes a bug

The theory becomes clear once we look at the four scenarios where code without cancellation breaks in production.

1. Race conditions: the old response that overwrites the new one

The user types “apple” into a search filter. The request fires. Before it returns, they change it to “banana”. A second request fires. If the first response arrives after the second — something real networks do all the time — the screen shows “apple” products while the filter says “banana”.

Async/await alone has no way to prevent this. The solution is to cancel the previous request on every new search:

let currentController: AbortController | null = null

async function loadProducts(filter: string) {
  // Cancel the previous request, if any
  currentController?.abort()
  currentController = new AbortController()

  try {
    const response = await fetch(`/api/products?filter=${filter}`, {
      signal: currentController.signal
    })
    const data = await response.json()
    setProducts(data) // Only the most recent request gets here
  } catch (error) {
    if (error.name === 'AbortError') return // Canceled: ignore
    throw error
  }
}

Each new request kills the previous one. Only the most recent one updates state. No more race conditions.

2. Wasted resources: orphaned requests

The user opens a page, the request fires, and they navigate somewhere else before the response arrives. Without cancellation, the request carries on: the server processes it, the network transports it, the browser parses data nobody will ever see. Multiply that by thousands of users and the waste stops being theoretical.

With AbortController, the useEffect cleanup takes care of it:

useEffect(() => {
  const controller = new AbortController()

  fetch('/api/products', { signal: controller.signal })
    .then(res => res.json())
    .then(data => setProducts(data))
    .catch(error => {
      if (error.name !== 'AbortError') console.error(error)
    })

  return () => controller.abort() // Component unmounted? Cancel.
}, [])

3. Memory leaks in the React lifecycle

This is the previous scenario’s cousin, with an aggravating factor. When a component unmounts and a pending request tries to update its state, React throws the famous warning “Can’t perform a React state update on an unmounted component” — a symptom of a real memory leak.

The fix follows the same pattern: create the controller inside useEffect and abort it in the cleanup. This covers both unmounting and dependency changes (a rapidly changing userId, for instance, cancels the fetch for the previous user before starting the new one).

4. Timeout: the server that never responds

fetch has no native timeout. If the server hangs, the Promise stays pending indefinitely and the user stares at an eternal spinner. AbortController fills that gap:

async function fetchData() {
  const controller = new AbortController()
  const timeoutId = setTimeout(() => controller.abort(), 10_000)

  try {
    const response = await fetch('/api/data', { signal: controller.signal })
    clearTimeout(timeoutId)
    return await response.json()
  } catch (error) {
    clearTimeout(timeoutId)
    if (error.name === 'AbortError') {
      throw new Error('Timeout: server did not respond within 10s')
    }
    throw error
  }
}

Note: modern browsers offer the shortcut AbortSignal.timeout(10_000), which removes the need for a manual setTimeout in the simple timeout case.

The complete pattern for React

Putting it all together — flow with async/await, cancellation, timeout, cleanup, and error handling — this is the recommended pattern for any data-fetching component:

function ProductList({ category }: { category: string }) {
  const [products, setProducts] = useState<Product[]>([])
  const [loading, setLoading] = useState(false)
  const [error, setError] = useState<string | null>(null)

  useEffect(() => {
    const controller = new AbortController()

    async function loadProducts() {
      setLoading(true)
      setError(null)

      const timeoutId = setTimeout(() => controller.abort(), 10_000)

      try {
        const response = await fetch(`/api/products?category=${category}`, {
          signal: controller.signal
        })
        clearTimeout(timeoutId)

        if (!response.ok) throw new Error(`HTTP ${response.status}`)

        setProducts(await response.json())
      } catch (error) {
        clearTimeout(timeoutId)
        if (error.name === 'AbortError') return // Not a real error
        setError(error instanceof Error ? error.message : 'Unknown error')
      } finally {
        setLoading(false)
      }
    }

    loadProducts()

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

  if (loading) return <div>Loading...</div>
  if (error) return <div>Error: {error}</div>

  return (
    <ul>
      {products.map(p => <li key={p.id}>{p.name}</li>)}
    </ul>
  )
}

What this pattern guarantees:

Data integrity. State always reflects the most recent request. Older requests are canceled before they get a chance to overwrite anything.

Performance. No downloading, processing, or storing data that will never be used. The timeout prevents infinite waits.

Correct lifecycle. Automatic cleanup on unmount, no memory leaks, no React warnings.

Robust UX. Loading and error states always consistent with what’s actually happening.

When AbortController is unnecessary

Not every fetch needs cancellation. For a single, non-concurrent request — a form submission, for example — plain async/await is enough. The result will always be used, there are no competing requests, and the component doesn’t unmount midway.

The sign that you do need AbortController shows up when there’s concurrency or volatility: debounced search while the user types, frequently changing filters, components that mount and unmount rapidly, infinite-scroll pagination, or any request with a critical timeout.

A classic example is debounced search, where the two cancellation mechanisms (the timer’s and the request’s) work together in the cleanup:

useEffect(() => {
  const controller = new AbortController()

  const timeoutId = setTimeout(async () => {
    try {
      const response = await fetch(`/api/search?q=${query}`, {
        signal: controller.signal
      })
      setResults(await response.json())
    } catch (error) {
      if (error.name !== 'AbortError') console.error(error)
    }
  }, 300)

  return () => {
    clearTimeout(timeoutId) // Cancel the pending debounce
    controller.abort()      // Cancel the in-flight request
  }
}, [query])

Case study: mismatched timeouts

To wrap up, a real-world problem that illustrates all of this. In an application with a frontend and a backend, the frontend aborted requests at 30 seconds while the backend had a 60-second timeout. The result: for long-running operations, the client gave up before the server finished its work — the user saw an error, and the server completed a computation nobody would ever receive.

The fix combined the two adjustments from this article: aligning the frontend timeout with the backend’s (60 seconds for both) and using AbortController to produce a specific error message when the limit is hit:

async function handleEnhancePrompt() {
  const controller = new AbortController()
  const timeoutId = setTimeout(() => controller.abort(), 60_000)

  try {
    const res = await fetch(`/api/enhance-prompt/${provider}`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ prompt, sysInstruction }),
      signal: controller.signal
    })
    clearTimeout(timeoutId)
    // ... process the response
  } catch (e: unknown) {
    clearTimeout(timeoutId)
    if (e instanceof DOMException && e.name === 'AbortError') {
      handleEnhanceError({
        code: 'TIMEOUT',
        message: 'The analysis took too long. Try a shorter prompt or switch models.'
      })
      return
    }
    // ... other errors
  }
}

Aligned timeouts, a clear error message for the user, and no work thrown away.

Conclusion

The question “AbortController or async/await?” is badly framed. Async/await organizes the flow; AbortController manages cancellation. One without the other leaves gaps: flow without cancellation produces race conditions and leaks; cancellation without solid flow control produces unreadable code.

In React applications, the rule of thumb is simple: every fetch inside a useEffect deserves an AbortController in the cleanup. That's four extra lines that eliminate an entire family of hard-to-reproduce bugs — precisely the ones that only show up in production, with slow networks and impatient users.


메타데이터
post_id
d92fcd11fdf4
slug
abortcontroller-doesnt-replace-async-await-it-completes-what-was-missing-d92fcd11fdf4
url
https://medium.com/@contato.blense/abortcontroller-doesnt-replace-async-await-it-completes-what-was-missing-d92fcd11fdf4
canonical_url
https://medium.com/@contato.blense/abortcontroller-doesnt-replace-async-await-it-completes-what-was-missing-d92fcd11fdf4
author_url
https://medium.com/@contato.blense
status
ok
fetched_at
2026-07-10 14:51:46