
AbortController in Node.js and React - Complete Guide with 5 Examples
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
- ✅ Browser: Full support in all major browsers (MDN↗, Can I Use↗)
- ✅ Node.js: Stable since: v15.4.0↗ (December 2020)
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:
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.
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:
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:
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:
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:
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:
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:
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.
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.
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.
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.
// 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.
// 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.
// 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
// 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:
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!