![Managing Asynchronous Operations in Node.js with AbortController](/_next/image?url=%2Fimages%2Fblog%2F2025-02%2Fabortcontroller-node.jpg&w=3840&q=90)
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:
-
AbortController
: The primary interface for initiating the cancellation process. You create anAbortController
instance to control and manage one or more asynchronous operations. -
AbortSignal
: An object associated with theAbortController
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.
-
Create an
AbortController
instance to manage and initiate the abort operation:JavaScriptconst controller = new AbortController();
-
Get the
AbortSignal
from the controller:JavaScriptconst { signal } = controller;
-
Pass the
AbortSignal
to the asynchronous operation you want to control:JavaScriptconst response = await fetch("https://api.example.com/data", { signal, });
-
Abort the request if needed:
JavaScriptcontroller.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:
// 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.
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:
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:
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
:
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:
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.
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:
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:
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:
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):
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:
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:
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.