Advanced JavaScript Asynchronous With Async.js

Asynchronous JavaScript has changed the way we approach backend and frontend development. Still, writing asynchronous code remains complex, especially considering that many native JavaScript functions do not work well with async operations. This is exactly where the Async.js library comes in! As a set of powerful utility functions, Async provides everything you need to handle complex JavaScript async scenarios in just a few lines of code. Given how common these challenges are, it is no surprise that the library has over 50 million weekly downloads! Let’s learn why asynchronous programming is challenging in JavaScript, how Async.js aims to resolve these issues, what features the library offers, and how to use it in two real-world examples. JavaScript Asynchronous Development: Benefits and Challenges The introduction of Promises and the async and await constructs in JavaScript has revolutionized web development. Handling asynchronous operations is simpler than ever, as you can now write async code that looks and reads like synchronous code. For more information, take a look at our article on JavaScript asynchronous programming tips, tricks, and gotchas. Among the main benefits of using Promises along with async/await are improved code readability and simpler error handling through try...catch blocks. However, challenges do exist—such as managing complex asynchronous flows or iterating over async tasks. Sure, some of those issues can be mitigated with best practices for async programming in JavaScript, such as reducing the callback hell. Yet, the best solution might be to use a dedicated library like Async, which provides dozens of functions to streamline async logic. Learn more about Async in the chapter below! Async.js: A Library For Simplified Asynchronous JavaScript Async, also known as Async.js, is a utility module that provides powerful functions for working with asynchronous JavaScript. It supports Node.js and the browser, for simplified asynchronous programming in both the backend and the frontend. Async includes around 70 functions for dealing with asynchronous collections, control flow, and other useful operations—such as logging and memorization. The GitHub repository of the project boasts over 28k stars, and the npm package has almost 55 million weekly downlands. You can install Async via the async package with the command below: npm install async Or, if you want the pure ESM version of Async, install async-es: npm install async-es If you are a CommonJS user, you can now import async in your code with: const async = require("async"); Or, if you are an ESM user, import it as below: import async from "async"; Functions Offered By Async.js Time to explore the most useful methods provided by Async, categorized as in the official documentation in: Collections: Functions for manipulating collections, such as arrays and objects. Control Flow: Functions for controlling the flow through a script. Utilities: Utility functions for JavaScript async development. Note that each Async.js function accepts an optional callback as the last argument. If provided, the callback is executed when the function completes or if an error occurs. If the callback is omitted, the Async function returns a Promise. For example, you can use the Async map() function with a callback as follows: const async = require("async"); async.map( [1, 2, 3], (num, callback) => { setTimeout(() => callback(null, num * 2), 100); }, (err, results) => { if (err) { console.error(err); } else { console.log(results); // output: [2, 4, 6] } }, ); map() from Async.js concurrently applies an async transformation function to each item in a collection. It processes each item in parallel and returns a new array of results, preserving the original order of the items. Or, equivalently, without the callback: import async from "async"; async .map([1, 2, 3], (num) => { return new Promise((resolve) => { setTimeout(() => resolve(num * 2), 100); }); }) .then((results) => { console.log(results); // output: [2, 4, 6] }) .catch((err) => { console.error(err); }); The above code can also be re-written to use the asnyc/await syntax: import async from "async"; (async () => { try { const results = await async.map([1, 2, 3], async (num) => { return new Promise((resolve) => { setTimeout(() => resolve(num * 2), 100); }); }); console.log(results); // output: [2, 4, 6] } catch (err) { console.error(err); } })(); Collections Async provides more than 35 functions for handling JavaScript collections concurrently. Here, we will focus on the 5 most popular ones, but you can find them all in the documentation. Note that for most functions in this category, there are two additional special versions: functionNameLimit(): Accepts a limit argum

Jun 7, 2025 - 14:20
 0
Advanced JavaScript Asynchronous With Async.js

Asynchronous JavaScript has changed the way we approach backend and frontend development. Still, writing asynchronous code remains complex, especially considering that many native JavaScript functions do not work well with async operations. This is exactly where the Async.js library comes in!

As a set of powerful utility functions, Async provides everything you need to handle complex JavaScript async scenarios in just a few lines of code. Given how common these challenges are, it is no surprise that the library has over 50 million weekly downloads!

Let’s learn why asynchronous programming is challenging in JavaScript, how Async.js aims to resolve these issues, what features the library offers, and how to use it in two real-world examples.

JavaScript Asynchronous Development: Benefits and Challenges

The introduction of Promises and the async and await constructs in JavaScript has revolutionized web development. Handling asynchronous operations is simpler than ever, as you can now write async code that looks and reads like synchronous code. For more information, take a look at our article on JavaScript asynchronous programming tips, tricks, and gotchas.

Among the main benefits of using Promises along with async/await are improved code readability and simpler error handling through try...catch blocks. However, challenges do exist—such as managing complex asynchronous flows or iterating over async tasks.

Sure, some of those issues can be mitigated with best practices for async programming in JavaScript, such as reducing the callback hell. Yet, the best solution might be to use a dedicated library like Async, which provides dozens of functions to streamline async logic.

Learn more about Async in the chapter below!

Async.js: A Library For Simplified Asynchronous JavaScript

Async, also known as Async.js, is a utility module that provides powerful functions for working with asynchronous JavaScript. It supports Node.js and the browser, for simplified asynchronous programming in both the backend and the frontend.

Async includes around 70 functions for dealing with asynchronous collections, control flow, and other useful operations—such as logging and memorization. The GitHub repository of the project boasts over 28k stars, and the npm package has almost 55 million weekly downlands.

You can install Async via the async package with the command below:

npm install async

Or, if you want the pure ESM version of Async, install async-es:

npm install async-es

If you are a CommonJS user, you can now import async in your code with:

const async = require("async");

Or, if you are an ESM user, import it as below:

import async from "async";

Functions Offered By Async.js

Time to explore the most useful methods provided by Async, categorized as in the official documentation in:

  • Collections: Functions for manipulating collections, such as arrays and objects.
  • Control Flow: Functions for controlling the flow through a script.
  • Utilities: Utility functions for JavaScript async development.

Note that each Async.js function accepts an optional callback as the last argument. If provided, the callback is executed when the function completes or if an error occurs. If the callback is omitted, the Async function returns a Promise.

For example, you can use the Async map() function with a callback as follows:

const async = require("async");

async.map(
  [1, 2, 3],
  (num, callback) => {
    setTimeout(() => callback(null, num * 2), 100);
  },
  (err, results) => {
    if (err) {
      console.error(err);
    } else {
      console.log(results); // output: [2, 4, 6]
    }
  },
);

map() from Async.js concurrently applies an async transformation function to each item in a collection. It processes each item in parallel and returns a new array of results, preserving the original order of the items.

Or, equivalently, without the callback:

import async from "async";

async
  .map([1, 2, 3], (num) => {
    return new Promise((resolve) => {
      setTimeout(() => resolve(num * 2), 100);
    });
  })
  .then((results) => {
    console.log(results); // output: [2, 4, 6]
  })
  .catch((err) => {
    console.error(err);
  });

The above code can also be re-written to use the asnyc/await syntax:

import async from "async";

(async () => {
  try {
    const results = await async.map([1, 2, 3], async (num) => {
      return new Promise((resolve) => {
        setTimeout(() => resolve(num * 2), 100);
      });
    });
    console.log(results); // output: [2, 4, 6]
  } catch (err) {
    console.error(err);
  }
})();

Collections

Async provides more than 35 functions for handling JavaScript collections concurrently. Here, we will focus on the 5 most popular ones, but you can find them all in the documentation.

Note that for most functions in this category, there are two additional special versions:

  • functionNameLimit(): Accepts a limit argument, and runs a maximum of limit async operations at a time.
  • functionNameSeries(): Runs only a single async operation at a time to respect the order of the collection.

For instance, map() has the mapLimit() and mapSeries() versions.

detect

detect() returns the first item in a collection that meets an asynchronous condition. The test function is applied to the collection in parallel, so the result may not be the first item from left to right. If order matters, use detectSeries().

Example:

const numbers = [1, 2, 3, 4, 5];

detect(
  numbers,
  (num, callback) => {
    // sample async test to run on the current item
    setTimeout(() => {
      callback(null, num % 2 === 0);
    }, 100);
  },
  (err, result) => {
    if (err) {
      console.error(err);
    } else {
      console.log(result); // potential output: 2 (or 4)
    }
  },
);

each

each() simultaneously applies a given function to each item in a collection. If any of the function calls return an error, the main callback is immediately triggered with that error.

Example:

import each from "async/each";

const items = [1, 2, 3, 4];

each(
  items,
  (item, callback) => {
    // sample async operation to execute on the current item
    setTimeout(() => {
      console.log(item * 2);
      callback();
    }, 100);
  },
  (err) => {
    if (err) {
      console.error(err);
    } else {
      console.log("All items processed");
    }
  },
);

every

every() checks if all items in a collection meet an asynchronous condition. If any of the items fail the condition, it immediately calls the main callback with false.

import every from "async/every";

const numbers = [2, 4, 6, 8];

every(
  numbers,
  (num, callback) => {
    // sample async test to run on the current item
    setTimeout(() => {
      callback(null, num % 2 === 0);
    }, 100);
  },
  (err, result) => {
    if (err) {
      console.error(err);
    } else {
      console.log(result); // output: true (as all numbers are even)
    }
  },
); 

filter

filter() returns a new array with items from the collection that pass an asynchronous truth test, maintaining the original order.

Example:

import filter from "async/filter";

const numbers = [1, 2, 3, 4, 5, 6];

filter(
  numbers,
  (num, callback) => {
    // sample async test to run on the current item
    setTimeout(() => {
      callback(null, num % 2 === 0);
    }, 100);
  },
  (err, results) => {
    if (err) {
      console.error(err);
    } else {
      console.log(results); // output: [2, 4, 6]
    }
  },
);

reduce

reduce() combines items in a collection into a single value by applying an async function to each item, one at a time, using a given starting value.

Example:

import reduce from "async/reduce";

const numbers = [1, 2, 3, 4];

reduce(
  numbers,
  0,
  (acc, num, callback) => {
    // sample async function to combine items with
    setTimeout(() => {
      callback(null, acc + num);
    }, 100);
  },
  (err, result) => {
    if (err) {
      console.error(err);
    } else {
      console.log(result); // output: 10 (1 + 2 + 3 + 4)
    }
  },
);

Control Flow

Async offers over 25 functions for simplifying asynchronous control flow. Below, we will focus on the 3 functions. Explore them all in the documentation.

parallel

parallel() runs multiple asynchronous tasks at the same time. Each task starts immediately without waiting for others to complete. If any task returns an error, the main callback is triggered with that error. When all tasks finish, the results are returned in an array (or an object if an object is used for tasks).

Example:

import parallel from "async/parallel";

parallel(
  // sample parallel tasks
  [
    (callback) => setTimeout(() => callback(null, "Task 1"), 200),
    (callback) => setTimeout(() => callback(null, "Task 2"), 100),
  ],
  (err, results) => {
    if (err) {
      console.error(err);
    } else {
      console.log(results); // output: ["Task 1", "Task 2"]
    }
  },
);

queue

queue() creates a queue to handle tasks with a set concurrency limit. Tasks are processed in parallel up to the limit. New tasks wait in the queue until a worker becomes available. When each task completes, the callback is called.

Example:

import queue from "async/queue";

const taskQueue = queue((task, callback) => {
  console.log(`Processing ${task.name}`);
  // sample async task
  setTimeout(callback, 100);
}, 2); // limit concurrency to 2

// loading all tasks into the queue
taskQueue.push([{ name: "Task 1" }, { name: "Task 2" }, { name: "Task 3" }]);

// executing the tasks in the queue
taskQueue.drain(() => {
  console.log("All tasks completed");
});

series

series runs tasks one after the other, waiting for each to complete before starting the next. If a task fails, no further tasks are run, and the main callback is called with the error. When all tasks are complete, the results are returned in an array (or an object if an object is used).

Example:

import series from "async/series";

series(
  // sample async tasks to run sequentially
  [
    (callback) => setTimeout(() => callback(null, "First Task"), 200),
    (callback) => setTimeout(() => callback(null, "Second Task"), 100),
  ],
  (err, results) => {
    if (err) {
      console.error(err);
    } else {
      console.log(results); // output: ["First Task", "Second Task"]
    }
  },
);

Utilities

Async offers a few utility functions to make it easier to handle asynchronous tasks. We will analyze only 3 of them, but you can find them all in the documentation.

log

log() prints the result of an async function directly to the console. If the async function returns multiple values, each value is logged in order. This function only works in Node.js and in frontend applications that support console.log() and console.error().

Example:

import log from "async/log";

// sample async task
function asyncTask(callback) {
  setTimeout(() => callback(null, "Result 1", "Result 2"), 100);
}

log(asyncTask); 
// output in the console: 
// "Result 1" 
// "Result 2"

memoize

memoize() caches the result of an async function. This way, repeated calls with the same arguments use the cached result instead of running the function again. An optional hash function can be provided to customize how arguments are used as keys in the cache.

If you are not familiar with memoization, read our article on memoizating functions for performance.

Example:

import memoize from "async/memoize";

// sample async function
const slowFunction = (num, callback) => {
  setTimeout(() => callback(null, num * 2), 100);
};

const memoizedFunction = memoize(slowFunction);

memoizedFunction(5, console.log); // takes 100ms, logs "10"
memoizedFunction(5, console.log); // cached, logs "10" immediately

timeout

timeout() sets a maximum time limit on an async function. If the function does not finish within the specified time, it returns a ETIMEDOUT error instead.

Example:

import timeout from "async/timeout";

// sample async task
function longTask(callback) {
  setTimeout(() => callback(null, "Done!"), 200);
}

// apply the timeout function
const limitedTask = timeout(longTask, 100);

limitedTask((err, result) => {
  if (err) {
    console.error(err.message); // output: "Callback function timed out."
  } else {
    console.log(result);
  }
});

Async.js in Action

Now that you have an idea of what Async.js has to offer, you are ready to see it in action in real-world scenarios!

Async Loop Functions

Functions like forEach(), reduce(), map(), and filter() are among the most common in JavaScript development. Still, challenges arise when using these methods with asynchronous calls or promises. In this case, results may not be what you would expect. For more information, follow the 4-part guide on async loops, and why they fail.

For example, see this async function to simulate an API call:

function asyncCall(timeToWait, dataToReturn) {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve(dataToReturn);
    }, timeToWait);
  });
}

This returns a Promise that resolves after a specified delay, just like what would happen when calling an endpoint.

Now, use forEach() to call the above function on each element in an array:

const values = [1, 2, 3, 5, 8];

console.log("START using forEach");
values.forEach(async (value) => {
  const data = await asyncCall(1000 * value, `data #${value}`);
  console.log(data);
});
console.log("END using forEach");

Run the code and the output will be:

START using forEach
END using forEach
data #1
data #2
data #3
data #5
data #8

As you can see, forEach() ends immediately, while the results from asyncCall() are logged only afterward. This occurs because forEach() does not wait for the Promises to resolve. In particular, it launches all asynchronous tasks, but it returns immediately. Thus, the async logic defined in the async callbacks passed to forEach() executes only after forEach() has returned.

To properly handle asynchronous calls in an iteration, you can use Promises with a custom asyncForEach function:

const asyncForEach = (array, callback) => {
  return array.reduce((promise, item) => {
    return promise.then(() => callback(item));
  }, Promise.resolve());
};

console.log("START using asyncForEach");
asyncForEach(values, async (value) => {
  const data = await asyncCall(1000 * value, `data #${value}`);
  console.log(data);
}).then(() => console.log("END using asyncForEach"));

Thanks to Promise chaining with array.reduce(), you can control the flow of execution and ensure that each async operation completes in series.

With this approach, the output will be:

START using asyncForEach
data #1
data #2
data #3
data #5
data #8
END using asyncForEach

While that solution works as desired, it introduces complexity and boilerplate code. This is where the Async.js library comes into play!

The eachSeries() function from Async.js is designed to execute asynchronous tasks one at a time. Here is how you can rewrite the iteration logic with using eachSeries():

const async = require("async");

// function asyncCall(timeToWait, dataToReturn) { ... }

console.log("START using Async.js");
async.eachSeries(
  values,
  async (value, callback) => {
    const data = await asyncCall(1000 * value, `data #${value}`);
    console.log(data);
    callback(); // signal completion of the current iteration
  },
  () => {
    console.log("END using Async.js");
  },
);

The new logic is much easier to read and maintain, eliminating the cumbersome Promise management introduced in the previous solution.

Once again, the output will be the expected one:

START using Async.js
data #1
data #2
data #3
data #5
data #8
END using Async.js

Wonderful! This is the power of Async.js.

Queuing Async Requests

To understand the capabilities of Async, let's consider another scenario.

Suppose you want to perform web scraping on a site with many pages. If you do not know what that entails, follow our guide on web scraping in Node.js. To speed up the process, the best practice is to send multiple simultaneous requests. At the same time, you do not want to overload the server with too many requests. So, you need to limit the number of concurrent requests in some way.

Now, imagine that you have a list of URLs that you want to scrape for data from:

const urls = [
  "https://example.com/page1",
  "https://example.com/page2",
  "https://example.com/page3",
  "https://example.com/page4",
  "https://example.com/page5",
  "https://example.com/page6",
  "https://example.com/page7",
  "https://example.com/page8",
  "https://example.com/page9",
  "https://example.com/page10",
];

To avoid flooding the site’s server, you want to have no more than 5 pending requests at the same time.

Implementing the desired behavior with vanilla JavaScript involves custom queuing logic. Instead, Async.js simplifies the process with its queue() method. This enables you to initiate new requests as soon as previous ones complete, as shown below:

const async = require("async");

// async function to scrape data from a URL
const scrapeUrl = async (url, callback) => {
  // scraping logic...
};

// list of URLs to scrape
const urls = [
  // omitted for brevity...
];

// create a queue with a concurrency limit of 5
const queue = async.queue(scrapeUrl, 5);

// add the URLs to the queue
urls.forEach((url) => {
  queue.push(url, (error) => {
    if (err) {
      console.error(`Failed to process ${url}:`, error);
    }
  });
});

// launch the scraping logic
queue.drain(() => {
  console.log("All URLs have been scraped successfully!");
});

When using queue() in Async, you first need to register all async tasks with the push() method. Then, you can execute them all simultaneously with the specified concurrency limit with drain(). Thanks to how a queue works, if the first request completes before the other four are still pending, the scraper will immediately send another request.

With Async, handling async queues has never been easier!

Conclusion

Async is a popular JavaScript library that addresses the limitations of native JavaScript's asynchronous programming features. For instance, it provides specialized versions of functions like map(), each(), every(), and reduce() that work well with asynchronous functions.

Here, you saw why async programming in JavaScript is not always a piece of cake. While you could address most issues with custom code, using a dedicated library like Async makes everything easier.

As shown above, integrating Async functions into your code is simple, whether you prefer the callback style or Promise-based syntax!