AbortController in Node.js and React - Complete Guide with 5 Examples
Published on May 23, 2025   •   12 min read

AbortController in Node.js and React - Complete Guide with 5 Examples

Jarek CeborskiJarek Ceborski

AbortController is like a stop button for your JavaScript code. It lets you cancel things that are running - like API calls, streams, or event listeners.

Personally, after years of coming up with overly complex and tedious ways to remove listeners in React components, discovering AbortController and specifically AbortSignal greatly simplified my life!

Most developers don't know about this powerful feature. But once you learn it, you'll use it everywhere. Here's what happens when you don't have proper cancellation:

  • Users click "Cancel" but the request keeps running e.g. AI model tokens are wasted
  • Components unmount but event listeners stay active
  • Expensive operations waste CPU cycles unnecessarily
  • Apps feel sluggish and unresponsive
  • Server costs increase from wasted resources

Compatibility

What is AbortController?

AbortController is a Web API that lets you cancel operations. It works with:

  • Fetch requests
  • Event listeners
  • Streams
  • Child processes
  • Any operation that supports AbortSignal

Here's how it works:

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

// Later, when you want to cancel
controller.abort()

Simple, right?

I think because it's a separate class, it's not intuitive at first what it's used for. Intuitively, I'd expect cancelling fetch() in the Fetch API, cancelling stream() in the Stream class, etc.

Why You Need AbortController

Without AbortController, your app can have serious problems:

  • Memory leaks - Event listeners that never get removed
  • Hanging requests - API calls that take forever
  • Wasted resources - Operations that keep running e.g. AI model streaming response, when not needed
  • Poor user experience - Apps that feel slow and unresponsive

AbortController fixes all these issues with one simple pattern.

5 Real Examples of AbortController

Example 1: Cleaning Up Event Listeners in React

This is the most common use case. When your React component unmounts, you want to remove all event listeners.

JavaScript
function Component() {
  useEffect(() => {
    const controller = new AbortController()
    const signal = controller.signal

    // Add multiple event listeners
    document.addEventListener('click', handleClick, { signal })
    document.addEventListener('keydown', handleKeydown, { signal })
    window.addEventListener('resize', (e) => console.log('Window resized!'), { signal })

    function handleClick(e) {
      console.log('Clicked!')
    }

    function handleKeydown(e) {
      console.log('Key pressed!')
    }

    // Cleanup all listeners at once
    return () => controller.abort()
  }, [])

  return <div>My Component</div>
}

Clean and simple. Since I started using it, I never looked back, it makes my code is so much cleaner. It even works with anonymous arrow functions!

Example 2: Cancelling OpenAI Stream Responses

When working with AI APIs, users might want to stop a response mid-stream, and not pay for tokens after they cancel it. Here's how:

JavaScript
import OpenAI from 'openai'

async function generateText(prompt, onChunk) {
  const controller = new AbortController()
  const openai = new OpenAI({ apiKey: 'your-key' })

  try {
    const stream = await openai.chat.completions.create(
      {
        model: 'gpt-4o',
        messages: [{ role: 'user', content: prompt }],
        stream: true,
      },
      { signal: controller.signal }
    )

    for await (const chunk of stream) {
      // ... process chunk ...
    }
  } catch (error) {
    if (error.name === 'AbortError') {
      console.log('Stream cancelled by user')
    }
  }

  // Return cancel function
  return () => controller.abort()
}

// Usage in React
function ChatComponent() {
  const [response, setResponse] = useState('')
  const [cancelFn, setCancelFn] = useState(null)

  const handleSubmit = async (prompt) => {
    const cancel = await generateText(prompt, (chunk) => {
      // ... append chunk to response ...
    })
    setCancelFn(() => cancel)
  }

  const handleCancel = () => {
    if (cancelFn) {
      cancelFn()
      setCancelFn(null)
    }
  }

  return (
    <div>
      <button onClick={handleCancel}>Stop Generation</button>
      {/* ... rest of chat UI ... */}
    </div>
  )
}

Key benefits:

  • Saves API costs when users stop early
  • Prevents wasted bandwidth
  • Better user control and UX

I remember when I built the first version of the Kerlig app, when after a user cancelled the response stream, it just stopped streaming in the UI, but the actual stream was still working, and tokens were being used, which cost money. Fortunately, I quickly fixed that!

Example 3: Smart Fetch with Timeout and User Cancellation

This combines AbortSignal.timeout() and user-triggered cancellation:

JavaScript
async function fetchWithTimeout(url, timeoutMs = 5000) {
  // Create signals for timeout and user cancellation
  const userController = new AbortController()
  const timeoutSignal = AbortSignal.timeout(timeoutMs)

  // Combine both signals
  const combinedSignal = AbortSignal.any([userController.signal, timeoutSignal])

  try {
    const response = await fetch(url, {
      signal: combinedSignal,
    })

    return await response.json()
  } catch (error) {
    if (error.name === 'AbortError') {
      if (timeoutSignal.aborted) {
        throw new Error('Request timed out')
      } else {
        throw new Error('Request cancelled by user')
      }
    }
    throw error
  }
}

// Usage in React
function DataComponent() {
  const [data, setData] = useState(null)
  const [loading, setLoading] = useState(false)
  const controllerRef = useRef(null)

  const loadData = async () => {
    setLoading(true)
    controllerRef.current = new AbortController()

    try {
      const result = await fetchWithTimeout('/api/data', 3000)
      setData(result)
    } catch (error) {
      console.error('Failed to load data:', error.message)
    } finally {
      setLoading(false)
    }
  }

  const cancelRequest = () => {
    if (controllerRef.current) {
      controllerRef.current.abort()
    }
  }

  return (
    <div>
      <button onClick={loadData} disabled={loading}>
        Load Data
      </button>
      {loading && <button onClick={cancelRequest}>Cancel</button>}
      {data && <pre>{JSON.stringify(data, null, 2)}</pre>}
    </div>
  )
}

Key benefits:

  • Automatic timeout handling
  • User can cancel anytime
  • Clear error messages for different cancellation types

Think about the last time you used a slow website. You probably clicked a button, waited 10-15 seconds, then gave up and left. That's exactly what happens to your users when requests hang indefinitely. I learned this the hard way when building a data dashboard - users would click "Generate Report" and wait forever while my slow database queries ran. Adding proper timeouts with clear feedback turned a frustrating experience into a smooth one. Users now see "Loading... (will timeout in 30s)" and can cancel anytime. The result? Much happier users and fewer support emails asking "Is the site broken?"

Example 4: Cancelling Child Processes in Node.js

When running external commands, you need a way to stop them. Node.js child_process methods natively support AbortSignal:

JavaScript
import { spawn } from 'child_process'

// Simple approach using native AbortSignal support
function runCommand(command, args, options = {}) {
  const controller = new AbortController()
  const { signal } = controller

  const child = spawn(command, args, {
    ...options,
    signal, // Native support in Node.js
  })

  child.on('error', (err) => {
    if (err.name === 'AbortError') {
      console.log('Process was cancelled')
    } else {
      console.error('Process error:', err)
    }
  })

  // Return both child and cancel function
  return {
    child,
    cancel: () => controller.abort(),
  }
}

// Usage example
async function buildProject() {
  const { child, cancel } = runCommand('npm', ['run', 'build'])

  // Cancel after 30 seconds
  setTimeout(() => {
    cancel()
  }, 30000)

  // Listen for completion
  child.on('close', (code) => {
    if (code === 0) {
      console.log('Build completed successfully')
    } else {
      console.log(`Build failed with code ${code}`)
    }
  })
}

Why this matters:

  • Prevents zombie processes
  • Saves server resources
  • Better error handling

Just a few years ago I built a Node.js app that processed user-uploaded videos. After users uploaded files through the web interface, my app would spawn FFmpeg child processes to compress and convert them. The problem? Some corrupted videos would cause FFmpeg to hang indefinitely, eating CPU and memory. Worse, if a user navigated away or closed their browser, the FFmpeg process kept running in the background. I had servers with dozens of zombie FFmpeg processes consuming all available resources. After implementing AbortController with child processes, I could properly kill FFmpeg when users leave, and added timeouts to detect stuck video processing. Now the servers stay healthy and resources don't get wasted on abandoned jobs.

Example 5: Real-Time Data Processing with Graceful Shutdown

This example shows how to handle real-time data streams that need clean shutdown:

JavaScript
class DataProcessor {
  constructor() {
    this.controller = new AbortController()
    this.signal = this.controller.signal
    this.isProcessing = false
  }

  async processStream(dataSource) {
    if (this.isProcessing) {
      throw new Error('Already processing')
    }

    this.isProcessing = true
    const results = []

    try {
      // Simulate real-time data processing
      while (!this.signal.aborted) {
        const batch = await this.fetchDataBatch(dataSource)

        if (batch.length === 0) {
          await this.sleep(1000) // Wait before next fetch
          continue
        }

        // Process each item
        for (const item of batch) {
          if (this.signal.aborted) break

          const processed = await this.processItem(item)
          results.push(processed)

          // Allow other operations
          await this.sleep(10)
        }
      }
    } catch (error) {
      if (error.name === 'AbortError') {
        console.log('Processing stopped gracefully')
      } else {
        throw error
      }
    } finally {
      this.isProcessing = false
    }

    return results
  }

  async fetchDataBatch(source) {
    const response = await fetch(source, {
      signal: this.signal,
    })
    return response.json()
  }

  async processItem(item) {
    // Simulate processing time
    await this.sleep(100)
    return { ...item, processed: true, timestamp: Date.now() }
  }

  sleep(ms) {
    return new Promise((resolve, reject) => {
      const timeout = setTimeout(resolve, ms)
      this.signal.addEventListener('abort', () => {
        clearTimeout(timeout)
        reject(new Error('Sleep aborted'))
      })
    })
  }

  stop() {
    this.controller.abort()
  }
}

// Usage
const processor = new DataProcessor()

// Start processing
processor
  .processStream('/api/data-stream')
  .then((results) => console.log('Processed:', results.length, 'items'))
  .catch((error) => console.error('Processing failed:', error.message))

// Stop after 10 seconds
setTimeout(() => {
  processor.stop()
}, 10000)

I ran into this issue while building a data synchronization service between our main database and analytics warehouse. The service would process batches of records every few minutes, transforming and moving thousands of rows. During deployments, when Kubernetes tried to gracefully shut down the old pods, the data processing would keep running and block the shutdown for minutes. Worse, if we force-killed the pods, we'd end up with half-processed batches and data inconsistencies. Some records would be duplicated in the next run, others would be missing. After implementing AbortController, the service can detect shutdown signals and cleanly abort ongoing batch processing. Now deployments are faster, data stays consistent, and we never have to manually fix corrupted sync states. The key insight: background services need graceful shutdown just as much as user-facing requests.

AbortSignal Helper Methods

AbortSignal.timeout()

Creates a signal that aborts after a set time. Useful when you want automatic cancellation without user intervention:

JavaScript
const signal = AbortSignal.timeout(5000) // 5 seconds

fetch('/api/data', { signal })
  .then((response) => response.json())
  .catch((error) => {
    if (error.name === 'AbortError') {
      console.log('Request timed out')
    }
  })

When to use:

  • Preventing hanging requests - APIs that might be slow or unresponsive
  • Background operations - Data syncing, health checks, monitoring
  • Mobile apps - Limited battery/data, need faster timeouts
  • Server-side requests - Prevent one slow service from blocking others

AbortSignal.any()

Combines multiple signals - aborts when any signal aborts. Perfect for "first to win" scenarios:

JavaScript
const userSignal = userController.signal
const timeoutSignal = AbortSignal.timeout(3000)
const combinedSignal = AbortSignal.any([userSignal, timeoutSignal])

fetch('/api/data', { signal: combinedSignal })

When to use:

  • User cancellation + timeout - Either user clicks cancel OR request times out
  • Component lifecycle + operation timeout - React component unmounts OR long operation finishes
  • Multiple cancellation sources - Parent process shutdown OR child process timeout
  • Race conditions - First successful response wins, cancel the rest

Best Practices

1. Always Handle AbortError

Prevent your app from crashing when operations are cancelled.

JavaScript
try {
  await fetch(url, { signal })
} catch (error) {
  if (error.name === 'AbortError') {
    // Handle cancellation
    return
  }
  // Handle other errors
  throw error
}

2. Clean Up in React

Prevent memory leaks by cancelling operations when components unmount.

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

  // Your async operations here

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

3. Provide User Feedback

Show users what's happening and give them control over long-running operations.

JavaScript
const [loading, setLoading] = useState(false)
const [canCancel, setCanCancel] = useState(false)

const handleOperation = async () => {
  setLoading(true)
  setCanCancel(true)

  try {
    await longRunningOperation()
  } finally {
    setLoading(false)
    setCanCancel(false)
  }
}

Essential UX tips for cancellation:

Make cancel buttons obvious - Use clear text like "Cancel" or "Stop", not just an X icon. Users should never guess what will stop an operation.

Show immediate feedback - When users click cancel, immediately disable the button and show "Cancelling..." so they know their click registered.

Handle the cancelled state gracefully - Don't show error messages when users cancel. Instead, show neutral messages like "Operation cancelled" or just return to the previous state.

Provide progress context - Show what's happening so users know if they want to wait or cancel: "Processing 15 of 100 files..." instead of just "Loading..."

Enable cancellation early - Let users cancel as soon as the operation starts, not just after some delay. Fast operations can still hang unexpectedly.

Common Mistakes to Avoid

1. Not Checking if Already Aborted

Calling abort() multiple times throws an error and can crash your app.

JavaScript
// Bad
controller.abort()
controller.abort() // Error!

// Good
if (!controller.signal.aborted) {
  controller.abort()
}

2. Forgetting to Handle AbortError

Unhandled AbortError exceptions will crash your application unexpectedly.

JavaScript
// Bad - unhandled AbortError crashes your app
fetch(url, { signal })

// Good
try {
  await fetch(url, { signal })
} catch (error) {
  if (error.name !== 'AbortError') {
    throw error
  }
}

3. Creating New Controllers on Every Render

New controllers on each render break cancellation and cause memory leaks.

JavaScript
// Bad - creates new controller on every render
function Component() {
  const controller = new AbortController()
  // ...
}

// Good - stable reference
function Component() {
  const controllerRef = useRef(new AbortController())
  // ...
}

Quick Reference

JavaScript
// Create controller
const controller = new AbortController()

// Get signal
const signal = controller.signal

// Check if aborted
if (signal.aborted) {
  /* ... */
}

// Listen for abort
signal.addEventListener('abort', () => {
  console.log('Aborted!')
})

// Abort
controller.abort()

// Timeout signal
const timeoutSignal = AbortSignal.timeout(5000)

// Combined signals
const combined = AbortSignal.any([signal1, signal2])

Ready to make your JavaScript more robust? Try these examples in your next project!

Using AbortController Beyond Fetch

While most examples show fetch cancellation, AbortController works with many other operations:

Cancelling File Operations

Large file operations can benefit from cancellation:

JavaScript
import { createReadStream } from 'fs'

function processLargeFile(filename) {
  const controller = new AbortController()
  const { signal } = controller

  const stream = createReadStream(filename, { signal })

  stream.on('data', (chunk) => {
    // ... process chunk ...
    console.log(`Processing ${chunk.length} bytes`)
  })

  stream.on('error', (error) => {
    if (error.code === 'ABORT_ERR') {
      console.log('File processing cancelled')
    }
  })

  // Cancel after 5 seconds
  setTimeout(() => {
    controller.abort()
    console.log('File operation timed out')
  }, 5000)

  return controller
}

Conclusion

AbortController is a powerful tool that every developer should know. It helps you:

  • Prevent memory leaks
  • Improve user experience
  • Write cleaner code
  • Handle timeouts gracefully
  • Cancel operations safely

Start with the simple examples and gradually work up to the complex ones. Your users (and your memory usage) will thank you.

Ready to try these examples? The best way to learn AbortController is by experimenting in a REPL environment where you can see immediate results. You can use your browser's console, Node.js REPL, or a live playground like Quokka.js in your editor. Then you can try LocalCan for testing on a mobile phone, different device, or to share using a Public URL with someone else!