BitlystBitlyst
PostsSeriesTopicsAbout

Bitlyst

Short, diagram-first posts on JavaScript, React & Next.js. Follow via RSS or the newsletter.

© 2026 Mohsen Fallahnejad

PostsSeriesTopicsLinksAboutRSSGitHubX
Posts/javascript
Intermediate4 min read

AbortController & AbortSignal

Everything you need to cancel async work in JS: fetch, streams, custom tasks, React patterns, Node notes, timeouts, races, and common pitfalls.

Mohsen Fallahnejad

Mohsen Fallahnejad

Sep 23, 2025Discuss

AbortController is the standard way to cancel async work in modern JavaScript. It pairs with AbortSignal, which you pass to tasks so they can stop immediately.

AbortController & AbortSignalAbortController & AbortSignal


1) TL;DR#

  • Create a controller → pass controller.signal to your async work.
  • Call controller.abort(reason?) to cancel; consumers see an AbortError (or signal.reason).
  • Works with fetch, streams, and your own functions.
js
const c = new AbortController()
const resP = fetch('/api/data', { signal: c.signal })
// later...
c.abort('user navigated away')
try { await resP } catch (e) { if (e.name === 'AbortError') /* ignore */ }

2) Core API (with reason support)#

js
const c = new AbortController()
const { signal } = c

signal.aborted        // boolean
signal.reason         // any (why it was aborted)

c.abort(new DOMException('Timeout', 'AbortError'))
// or: c.abort('User left the page')

Tip: If you pass a reason, propagate it in your own tasks. fetch will still reject with AbortError.


3) Fetch + Timeouts#

A) Easiest: AbortSignal.timeout(ms)

js
// Modern browsers & Node 18+
const res = await fetch('/slow', { signal: AbortSignal.timeout(3000) })

B) Manual timer

js
const c = new AbortController()
const id = setTimeout(() => c.abort(new DOMException('Timeout', 'AbortError')), 3000)
try {
  const res = await fetch('/slow', { signal: c.signal })
  // use res
} catch (e) {
  if (e.name !== 'AbortError') throw e
} finally {
  clearTimeout(id)
}

C) Race utilities

js
// winner-takes-all -> cancel the losers
const controllers = [new AbortController(), new AbortController()]
const [a, b] = controllers.map(c => fetch('/mirror', { signal: c.signal }))
const winner = await Promise.any([a, b])
controllers.forEach(c => c.abort('lost the race'))

4) Make Your Own Functions Abortable#

js
export function wait(ms, signal) {
  return new Promise((resolve, reject) => {
    const id = setTimeout(resolve, ms)
    const onAbort = () => { clearTimeout(id); reject(new DOMException('Aborted', 'AbortError')) }
    if (signal.aborted) return onAbort()
    signal.addEventListener('abort', onAbort, { once: true })
  })
}

Propagate reason:

js
const onAbort = () => reject(signal.reason ?? new DOMException('Aborted', 'AbortError'))

5) Streams & Readers (Browser + Node)#

js
const c = new AbortController()
const res = await fetch('/stream', { signal: c.signal }) // can be aborted
const reader = res.body.getReader({ signal: c.signal })   // abort affects reads too

// later
c.abort()

Node: fetch in Node 18+ also supports abort; for streams, pipe/reader operations should react to abort and close resources.


6) React Patterns#

A) Cancel on unmount (and on deps change)

tsx
useEffect(() => {
  const c = new AbortController()
  ;(async () => {
    try {
      const r = await fetch('/api/search?q=' + q, { signal: c.signal })
      setData(await r.json())
    } catch (e) {
      if (e.name !== 'AbortError') console.error(e)
    }
  })()
  return () => c.abort('component unmounted or q changed')
}, [q])

B) Latest-typed value wins (typeahead)

tsx
const ref = useRef<AbortController | null>(null)
async function onType(v: string) {
  ref.current?.abort('superseded')
  const c = new AbortController()
  ref.current = c
  try {
    const r = await fetch('/api?q=' + v, { signal: c.signal })
    setOptions(await r.json())
  } catch (e) { if (e.name !== 'AbortError') console.error(e) }
}

7) Small Utilities (copy‑paste)#

js
// create a controller that auto-aborts after ms
export const withTimeout = (ms = 5000) => AbortSignal.timeout(ms)

// combine multiple signals -> aborted if ANY aborts
export function anySignal(...signals) {
  const c = new AbortController()
  const onAbort = (s) => c.abort(s.reason ?? new DOMException('Aborted', 'AbortError'))
  signals.forEach(s => s.addEventListener('abort', () => onAbort(s), { once: true }))
  return c.signal
}

Usage:

js
const c = new AbortController()
const s = anySignal(c.signal, AbortSignal.timeout(3000))
fetch('/x', { signal: s })

8) Common Pitfalls & Gotchas#

  • Not wiring the signal → pass { signal } everywhere the task supports it.
  • Forgetting cleanup → clear timers and remove listeners on abort (use { once: true }).
  • Swallowing all errors → only ignore AbortError; surface real failures.
  • Global controller reuse → create fresh controllers per operation to avoid accidental cross‑cancels.
  • Overriding reason → if you care about why, use abort(reason) and read signal.reason in custom code.

9) Quick Cheatsheet#

NeedDo this
Cancel slow fetchfetch(url, { signal: AbortSignal.timeout(ms) })
Cancel on unmountCreate AbortController in useEffect, abort in cleanup
Cancel prior request (search)Keep last controller in ref, abort before new fetch
Cancel a batchShare one controller across requests and call abort()
Keep “why” it was cancelledcontroller.abort('reason'); signal.reason

Happy cancelling ✨ Use AbortController to keep your apps snappy, correct, and memory‑leak free.

Filed under#javascript#abortcontroller#abortsignal#fetch#async#react#node

Was this worth your time?

One tap tells me what to write more of.

Mohsen Fallahnejad

Written by

Mohsen Fallahnejad

Frontend engineer explaining JavaScript, React & Next.js internals in posts you can finish over coffee. More about me

X ↗GitHub ↗LinkedIn ↗
← OlderHow useState Works Under the Hood (and Why It Needs use client)Newer →Why setState Doesn’t Cause Hydration Errors

Start the discussion

Questions, corrections, a better approach? I read and reply to every comment.

Read next

queueMicrotask Explained: The Smallest Async Queue in JavaScript

What queueMicrotask does, how it differs from Promise.then, and how microtasks really work under the hood.

Dec 31, 20251 min read1

Why async/await Doesn’t Make JavaScript Code Parallel

Async/await feels like parallel code, but it isn’t. Here’s why JavaScript is still single-threaded and how async really works.

Dec 30, 20253 min read1

How Promise.all Works in a Single-Threaded JavaScript World

If JavaScript is single-threaded, how can Promise.all handle multiple requests at the same time? A deep dive into the event loop and async I/O.

Dec 30, 20252 min read1

Get new posts in your inbox

No spam. Unsubscribe any time.

4 min left
Discuss👍 React
👍