javascript

Managing Asynchronous Operations in Node.js with AbortController

Damilola Olatunji

Damilola Olatunji on

Managing Asynchronous Operations in Node.js with AbortController

In Node, managing asynchronous operations effectively (especially those involving I/O, like network requests or file system access) is crucial to prevent resource depletion. Managing async operations well also helps maintain optimal application performance when the results of operations are no longer required.

The AbortController API addresses this need by providing a standardized mechanism to terminate operations gracefully. By incorporating this API into your Node.js programs, you'll have greater control over potentially long-running tasks, prevent memory leaks, and ultimately build more robust and responsive applications.

But before we see how to use AbortController, let's briefly touch on what it does.

What is the AbortController API?

AbortController is a web API available in modern browsers and Node.js environments. It provides a way to abort network requests or other asynchronous operations supporting it.

This is especially useful for outgoing network operations where you might need to cancel a request that takes too long, or if the response is no longer needed.

The API consists of two main interfaces:

  1. AbortController: The primary interface for initiating the cancellation process. You create an AbortController instance to control and manage one or more asynchronous operations.

  2. AbortSignal: An object associated with the AbortController instance that carries the abort status (whether the operation has been canceled or not) to the asynchronous operation.

How AbortController Works in Node.js

Here's a basic overview of how the AbortController and AbortSignal interfaces work in tandem to cancel asynchronous operations in Node.

  1. Create an AbortController instance to manage and initiate the abort operation:

    JavaScript
    const controller = new AbortController();
  2. Get the AbortSignal from the controller:

    JavaScript
    const { signal } = controller;
  3. Pass the AbortSignal to the asynchronous operation you want to control:

    JavaScript
    const response = await fetch("https://api.example.com/data", { signal, });
  4. Abort the request if needed:

    JavaScript
    controller.abort(); // aborting with a reason which can be any JavaScript value controller.abort("Result no longer needed");

In subsequent sections of this tutorial, you will see some practical examples of how to effectively use AbortController in real-world scenarios.

Canceling Network Requests with AbortController

One of the most common applications of the AbortController interface is canceling ongoing network requests, which allows for greater control over your web applications' network interactions.

While the fetch API is used here to demonstrate this pattern, the concepts are applicable to other HTTP request libraries that support AbortController (such as Axios, Got, or Ky).

Here's the pattern in action:

JavaScript
// Create an AbortController instance const controller = new AbortController(); const { signal } = controller; const timeoutId = setTimeout(() => { controller.abort(); }, 1000); // Abort after one second try { const response = await fetch("https://jsonplaceholder.typicode.com/todos/1", { signal, }); clearTimeout(timeoutId); // Clear timeout if the fetch request succeeds if (!response.ok) { throw new Error("Network response was not ok"); } const data = await response.json(); console.log(data); } catch (error) { console.error(error); }

This snippet fetches data from a JSONPlaceholder API endpoint but aborts the request if it takes longer than a second. An AbortController instance is created to manage the fetch operation, and a setTimeout() triggers the controller's abort() method if the request isn't completed in the specified time.

If the request takes over a second, an AbortError will be printed to the console. You can also try lowering the timeout if necessary to trigger the error.

Shell
DOMException [AbortError]: This operation was aborted at node:internal/deps/undici/undici:12502:13 at async file:///home/ayo/dev/demo/nodejs-abortcontroller/index.js:10:20

Providing a Reason for Aborting

You can provide a reason argument to the abort() method to replace the default error object for aborted signals:

JavaScript
const timeoutId = setTimeout(() => { controller.abort('Request timed out'); }, 1000); . . . try { } catch (error) { console.error(error); // If triggered by abort(), error will be the reason string }

Simplified Timeouts with AbortSignal.timeout()

This pattern of canceling network requests after a fixed timeout is so common that a static timeout() method was added to the AbortSignal interface to simplify such cases:

JavaScript
try { const response = await fetch("https://jsonplaceholder.typicode.com/todos/1", { signal: AbortSignal.timeout(1000), // Abort after a second }); if (!response.ok) { throw new Error("Network response was not ok"); } const data = await response.json(); console.log(data); } catch (error) { console.log(error); }

If the provided timeout is exceeded and the operation is aborted, you'll see a TimeoutError:

Shell
DOMException [TimeoutError]: The operation was aborted due to timeout at node:internal/deps/undici/undici:12502:13 at async file:///home/ayo/dev/demo/nodejs-abortcontroller/index.js:2:20

Canceling Ongoing Streams

The AbortController interface isn't just for network requests; it's equally adept at managing streams, often used to handle large files or continuous data sources.

You can gracefully terminate stream operations by integrating AbortController into readable or writable streams. This helps to free up server resources and improve responsiveness when the data is no longer needed.

Let's look at a quick example:

JavaScript
import Fastify from "fastify"; import fs from "node:fs"; import { mkdir } from "node:fs/promises"; import { pipeline } from "node:stream/promises"; import fastifyMultipart from "@fastify/multipart"; const fastify = Fastify(); fastify.register(fastifyMultipart, { limits: { fileSize: 10000000, // The max file size in bytes }, }); fastify.post("/upload", async (req, reply) => { const controller = new AbortController(); const { signal } = controller; await mkdir("uploads", { recursive: true }); req.raw.on("close", () => { if (req.raw.aborted) { controller.abort(); } }); const data = await req.file(); const writeStream = fs.createWriteStream(`uploads/${data.filename}`, { signal, }); try { await pipeline(data.file, writeStream); // Use pipeline for promise-based stream handling reply.send({ message: "File uploaded successfully" }); } catch (err) { if (err.name === "AbortError") { console.log("Request closed, upload aborted by user..."); reply .code(499) .send({ message: "Request disconnected during file upload" }); } else { reply.code(500).send({ message: "Error uploading file" }); } } }); fastify.listen({ port: 3000 }, (err) => { if (err) throw err; console.log(`Server listening on ${fastify.server.address().port}`); });

This code snippet sets up a Fastify server with a single /upload endpoint that uses the @fastify/multipart plugin to handle file uploads. The request handler uses a write stream to save the uploaded file to disk and passes the signal from the AbortController so that the writing operation can be canceled if needed.

To detect if the user abandons the current request, the close event is listened to, and the aborted property is checked within the event handler. If it's true, the abort() method is called on the controller instance to cancel the writable stream accordingly.

To test this out, you can use the curl command below, which simulates a slow upload by limiting the upload speed to 10KB per second. Ensure the specified file is at least a few hundred kilobytes.

Shell
curl -X POST http://localhost:3000/upload \ -F "file=@/path/to/file" \ --limit-rate 10K

After a few seconds, cancel the request with Ctrl-C and look at your server console. You should see:

Shell
Request closed, upload aborted by user...

In the same manner, you can cancel any other asynchronous operation that supports the AbortController API. Some examples include fs.readFile(), fs.writeFile(), the timers/promises API, child_process.spawn(), and others.

Combining Multiple Signals

So far, you've learned how to use the AbortController to prevent slow outgoing requests from consuming resources and ensure that aborted requests do not continue to use resources allocated for the request.

In this section, we'll explore how to combine multiple cancellation signals for a single operation, allowing it to be canceled by whichever signal is triggered first.

Let's extend the previous example so that uploads from very slow clients are also terminated. You only need to make the following change:

JavaScript
const writeStream = fs.createWriteStream(`uploads/${data.filename}`, { // This signal will abort when the request is cancelled or 5 seconds have elapsed // whichever is sooner signal: AbortSignal.any([signal, AbortSignal.timeout(5000)]), });

The AbortSignal.any() static method combines multiple abort signals into a single unified signal, which is aborted as soon as any component signals are aborted.

The reason for the abortion is determined by the first signal triggered. If any of the signals are already in an aborted state when AbortSignal.any() is called, the resulting unified signal will also be immediately aborted.

Combining the signals in this manner ensures that neither canceled requests nor slow clients cause your server to waste resources on incomplete or unnecessary operations.

Integrating the AbortSignal API with Custom Functions

To build your own "abortable" APIs—functions or promises that can be gracefully canceled, you must seamlessly integrate the AbortController and AbortSignal mechanisms.

Let's break down the steps involved and illustrate them with a practical example. The first step is to ensure that your function accepts an AbortSignal reference as part of its options object:

JavaScript
async function myAbortableAPI(options = {}) { const { signal } = options; // Extract signal from options // rest of your logic }

In the function body, immediately check to see if the signal has already been triggered (and cancel the operation if so, to prevent starting unnecessary work):

JavaScript
async function myAbortableAPI(options = {}) { const { signal } = options; // Extract signal from options if (signal?.aborted === true) { throw new Error(signal.reason); } }

Next, listen for the abort event on AbortSignal and set up a callback function that performs cleanup operations when the event is triggered:

JavaScript
async function myAbortableAPI(options = {}) { const { signal } = options; // Extract signal from options if (signal?.aborted === true) { throw new Error(signal.reason); } const handleAbort = () => { console.log(event.type); // Prints 'abort' }; if (signal) { // ensure that the event listener is removed as soon as the 'abort' event is handled. signal.addEventListener("abort", handleAbort, { once: true }); } }

Now you can proceed with the main asynchronous logic of your function. Make sure you include a finally block to remove the event listener in case the operation is never aborted:

JavaScript
async function myAbortableAPI(options = {}) { const { signal } = options; // Extract signal from options if (signal?.aborted === true) { throw new Error(signal.reason); } const handleAbort = (event) => { console.log(event.type); // Prints 'abort' }; if (signal) { signal.addEventListener("abort", handleAbort, { once: true }); } try { // Run some asynchronous code } finally { if (signal) { signal.removeEventListener("abort", handleAbort); } } }

Remember that this pattern is not limited to network requests; you can apply it to any asynchronous operation you want to make abortable.

And that's a wrap on the AbortController API!

Wrapping up

With AbortController under your belt, you can confidently manage all your asynchronous operations, whether canceling lingering network requests or gracefully shutting down long-running tasks.

Thanks for reading!

P.S. If you liked this post, subscribe to our JavaScript Sorcery list for a monthly deep dive into more magical JavaScript tips and tricks.

P.P.S. If you need an APM for your Node.js app, go and check out the AppSignal APM for Node.js.

Damilola Olatunji

Damilola Olatunji

Damilola is a freelance technical writer and software developer based in Lagos, Nigeria. He specializes in JavaScript and Node.js, and aims to deliver concise and practical articles for developers. When not writing or coding, he enjoys reading, playing games, and traveling.

All articles by Damilola Olatunji

Become our next author!

Find out more

AppSignal monitors your apps

AppSignal provides insights for Ruby, Rails, Elixir, Phoenix, Node.js, Express and many other frameworks and libraries. We are located in beautiful Amsterdam. We love stroopwafels. If you do too, let us know. We might send you some!

Discover AppSignal
AppSignal monitors your apps