Cancellable Promises in JavaScript with Abort Controller

Cancellable Promises in JavaScript with Abort Controller

Play this article

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)
    }
    // Register timers
    let timeout = null
    signal.addEventListener('abort', () => {
      // Cleanup
      clearTimeout(timeout)
      reject(signal.reason)
    })
    // Long running operation
    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)

// Abort all child processes after 5 seconds
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'

// Unique ID to identify the process
const uid = crypto.randomInt(0, 100)
console.log(`Example process (${uid}) started...`)

// Long running operation
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...')
// Execute exampleProcess.js
const childProcess = spawn('node', ['exampleProcess.js'], {
  stdio: 'inherit',
  signal: abortSignal
})

// Register error handler
childProcess.on('error', (error) => {
  if (error.name === 'AbortError') {
    console.log('Attempting to stop child process...')
  } else {
    console.log(error.toString())
  }
})

// Register exit handler (never called)
childProcess.on('exit', (code) => {
  console.log(`Child process exited with code ${code}`)
})

// Abort the child process after 5 seconds
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...')
// Execute exampleProcess.js
const childProcess = spawn('node', ['exampleProcess.js'], {
  stdio: 'inherit',
  signal: abortSignal
})

// Register error handler
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())
  }
})

// Register exit handler (never called)
childProcess.on('exit', (code) => {
  clearTimeout(killTimeout)
  console.log(`Child process exited with code ${code}`)
})

// Abort the child process after 5 seconds
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'

/**
 * Spawn a cancellable process asynchonously
 * @param {String} cmd
 * @param {readonly String[]} args
 * @param {import('node:child_process').SpawnOptions & {signal: AbortSignal, stdio: import('node:child_process').StdioOptions}} opts
 * @param {Boolean} forked Whether to fork a Node JS process instead of spawning a general process (default: false)
 * @returns {Promise<void>}
 */
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...')

// Start first instance of exampleProcess.js using spawn
spawnProcess('node', ['exampleProcess.js'], {
  signal: abortSignal,
  stdio: 'inherit'
}).catch(errorHandler)

// Start second instance of exampleProcess.js using fork
spawnProcess('exampleProcess.js', [], {
  signal: abortSignal,
  stdio: 'inherit'
}, true).catch(errorHandler)

// Abort all child processes on CTRL + C
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 :)