Imagine having the power to pull the plug on JavaScript promises or asynchronous functions midway!
Using JavaScript's AbortController, you can abort fetch requests both on the browser and in Node JS. Furthermore, in Node JS, this feature enables you to effortlessly cancel file system operations, fetch requests and spawned or forked child processes. It gets better: you can use it to even cancel your own custom promises or asynchronous functions.
So let's get to it then.
What is AbortController?
AbortController is a standard Web Interface (object) that allows you to cancel asynchronous functions before they finish executing. It is supported both on browsers and in Node JS. It behaves similarly to an event emitter such that when .abort() is called, it emits an abort event which is received by its AbortSignal instance. The AbortSignal class, which extends EventTarget , allows registration of abort event handlers which are responsible for cleaning up and then exiting the asynchronous function(s) promptly.
A ubiquitous use case of this is cancelling fetch requests on the browser. A user may change their mind about waiting around for their huge file upload to finish, so you may provide a means for upload cancellation like Google Drive does.
Cancelling fetch request
const abortController = new AbortController()
const abortSignal = abortController.signal
console.log('Initiating fetch')
fetch('https://google.com', { signal: abortSignal })
.then(() => console.log('Finished fetching'))
.catch((error) => console.log(error))
abortController.abort()
$ node index.js
Initiating fetch
DOMException [AbortError]: This operation was aborted
Cancelled async operations throw a DOMException error, which extends the Error class and has the name property set to AbortError. You might want to handle this error or the program will crash.
Cancelling multiple requests and error handling
A single abort signal can be used to cancel multiple asynchronous operations at once.
const abortController = new AbortController()
const abortSignal = abortController.signal
console.log('Initiating fetch')
const errorHandler = (error) => {
if (error.name === 'AbortError')
console.log('Fetch aborted')
}
fetch('https://google.com', { signal: abortSignal })
.then(() => console.log('Finished fetching Google'))
.catch(errorHandler)
fetch('https://duckduckgo.com', { signal: abortSignal })
.then(() => console.log('Finished fetching DuckDuckGo'))
.catch(errorHandler)
abortController.abort()
$ node index.js
Initiating fetch
Fetch aborted
Fetch aborted
Already aborted signals
However, an abort signal instance can only be aborted once. If there are any asynchronous operations yet to commence and they receive an already aborted signal, they will throw an AbortError immediately after they start executing.
const abortController = new AbortController()
const abortSignal = abortController.signal
console.log('Initiating fetch')
const errorHandler = (error) => {
if (error.name === 'AbortError') {
console.log('Fetch aborted')
}
}
abortController.abort()
console.log(abortSignal.aborted)
fetch('https://duckduckgo.com', { signal: abortSignal })
.then(() => console.log('Finished fetching DuckDuckGo'))
.catch(errorHandler)
$ node index.js
Initiating fetch
true
Fetch aborted
Cancelling custom promises
We can make our own promises cancellable. First, we have to check if the signal has already been aborted. Then we register an abort signal handler which rejects the promise.
const abortController = new AbortController()
const abortSignal = abortController.signal
async function longRunningOperation (/* ..., */ { signal }) {
return new Promise((resolve, reject) => {
if (signal.aborted) {
return reject(signal.reason)
}
let timeout = null
signal.addEventListener('abort', () => {
clearTimeout(timeout)
reject(signal.reason)
})
console.log('Long running operation started. Will run for 5 minutes...')
timeout = setTimeout(() => {
resolve()
}, 300_000)
})
}
const errorHandler = (error) => {
if (error.name === 'AbortError') {
console.log('Long running operation aborted.')
} else {
console.log(error.toString())
}
}
longRunningOperation({ signal: abortSignal })
.catch(errorHandler)
for (const signal of ['SIGINT', 'SIGTERM']) {
process.on(signal, () => {
console.log(`Terminated by user. (${signal} signal received).`)
abortController.abort()
})
}
$ node index.js
Long running operation started. Will run for 5 minutes...
^CTerminated by user. (SIGINT signal received).
Long running operation aborted.
It is possible to provide a reason when calling abort (.abort('reason')). However, this will change the abort error thrown to normal Error object with the reason as the error message.
Cancelling Node JS file operations
The following file system operations can be cancelled using abort controllers:
readFile
writeFile
watch
createReadStream
createWriteStream
Terminating spawned or forked processes in Node JS
Notice: This section is detailed and it might be a bit advanced
Creating child processes is a handy feature in Node JS. spawn can be used for parallel processing or for executing commands. fork is a variant of spawn that executes Node JS modules. Normally, child processes might not exit when the main process terminates. You might want to terminate these child processes before the main process exits.
AbortController offers a convenient way to terminate multiple child processes at once. It will be like calling .kill() on the child process(es) which will by default send a SIGTERM POSIX signal. This allows the child process(es) to clean up and then exit gracefully when the SIGTERM signal is received.
However, some processes may hang during the handling of the SIGTERM signal or outrightly ignore the signal and continue running. Let's explore that scenario:
Example of hanging spawned processes
exampleProcess.js
import crypto from 'crypto'
const uid = crypto.randomInt(0, 100)
console.log(`Example process (${uid}) started...`)
setInterval(() => {
console.log(`[${uid}] Still running...`)
}, 1000)
for (const signal of ['SIGINT', 'SIGTERM']) {
process.on(signal, () => {
console.log(`[${uid}] Received ${signal} signal`)
console.log(`[${uid}] But I just won't quit!`)
})
}
index.js
import { spawn } from 'child_process'
const abortController = new AbortController()
const abortSignal = abortController.signal
console.log('Main process started...')
const childProcess = spawn('node', ['exampleProcess.js'], {
stdio: 'inherit',
signal: abortSignal
})
childProcess.on('error', (error) => {
if (error.name === 'AbortError') {
console.log('Attempting to stop child process...')
} else {
console.log(error.toString())
}
})
childProcess.on('exit', (code) => {
console.log(`Child process exited with code ${code}`)
})
setTimeout(() => {
abortController.abort()
}, 5000)
If you run this process, you will realize that the child process (exampleProcess.js) continues to run even after index.js exits, even when you tap CTRL + C .
$ node index.js
Main process started...
Example process (11) started...
[11] Still running...
[11] Still running...
Attempting to stop child process...
[11] Received SIGTERM signal
[11] But I just won't quit!
[11] Still running...
Forceful termination timeout
The above-described issue can be easily remedied by setting a 5-second timeout when an abort error is thrown by a process. After the timeout has lapsed, a SIGKILL signal is sent. This signal cannot be ignored and will forcefully terminate the child process. If the child exits before the timeout has lapsed, then the timeout is cleared. Here is the modified script:
index.js
import { spawn } from 'child_process'
const abortController = new AbortController()
const abortSignal = abortController.signal
let killTimeout = null
console.log('Main process started...')
const childProcess = spawn('node', ['exampleProcess.js'], {
stdio: 'inherit',
signal: abortSignal
})
childProcess.on('error', (error) => {
if (error.name === 'AbortError') {
console.log('Attempting to stop child process...')
killTimeout = setTimeout(() => {
console.log('Forcefully terminating child process...')
childProcess.kill('SIGKILL')
}, 5000)
} else {
console.log(error.toString())
}
})
childProcess.on('exit', (code) => {
clearTimeout(killTimeout)
console.log(`Child process exited with code ${code}`)
})
setTimeout(() => {
abortController.abort()
}, 5000)
On forceful termination of a child process, the exit code may be null since the child process did not exit on its own.
Main process started...
Example process (17) started...
[17] Still running...
[17] Still running...
[17] Still running...
[17] Still running...
[17] Received SIGTERM signal
Attempting to stop child process...
[17] But I just won't quit!
[17] Still running...
[17] Still running...
[17] Still running...
[17] Still running...
[17] Still running...
Forcefully terminating child process...
Child process exited with code null
Refactoring forceful termination timeout code
Now let's tie it all together into a beautiful function. The exampleProcess.js file remains unchanged.
index.js
import { spawn, fork } from 'child_process'
async function spawnProcess (cmd, args, opts, forked = false) {
return new Promise((resolve, reject) => {
const childProcess = forked === true
? fork(cmd, args, opts)
: spawn(cmd, args, opts)
const command = `${cmd} ${args.join(' ')}`
let killTimeout = null
childProcess.on('error', (error) => {
if (error.name === 'AbortError') {
killTimeout = setTimeout(() => {
console.log(`Forcefully terminating $ ${command}...`)
childProcess.kill('SIGKILL')
}, 5000)
} else {
console.log(error.toString())
}
})
childProcess.on('exit', (code) => {
clearTimeout(killTimeout)
switch (code) {
case 0:
resolve()
break
case null:
reject(new Error(`Terminated $ ${command}`))
break
default:
reject(new Error(`Command exited with code ${code}`))
break
}
})
})
}
const abortController = new AbortController()
const abortSignal = abortController.signal
const errorHandler = (error) => { console.log(error.toString()) }
console.log('Main process started...')
spawnProcess('node', ['exampleProcess.js'], {
signal: abortSignal,
stdio: 'inherit'
}).catch(errorHandler)
spawnProcess('exampleProcess.js', [], {
signal: abortSignal,
stdio: 'inherit'
}, true).catch(errorHandler)
for (const signal of ['SIGINT', 'SIGTERM']) {
process.on(signal, () => {
console.log(`Terminated by user. (${signal} signal received).`)
abortController.abort()
})
}
$ node index.js
Main process started...
Example process (43) started...
Example process (37) started...
[43] Still running...
[37] Still running...
[43] Still running...
^CTerminated by user. (SIGINT signal received).
[37] Received SIGINT signal
[43] Received SIGINT signal
[43] But I just won't quit!
[37] But I just won't quit!
[43] Received SIGTERM signal
[43] But I just won't quit!
[37] Still running...
[37] Received SIGTERM signal
[37] But I just won't quit!
[43] Still running...
[37] Still running...
[43] Still running...
[37] Still running...
[43] Still running...
[37] Still running...
[43] Still running...
[37] Still running...
[43] Still running...
Forcefully terminating $ node exampleProcess.js...
[37] Still running...
Forcefully terminating $ exampleProcess.js ...
Error: Terminated $ node exampleProcess.js
Error: Terminated $ exampleProcess.js
There you have it. Go forth and utilize cancellable promises to make awesome stuff!
A lot of research and attention to detail went into making this article. Kindly leave a like, comment or correction :)