Asynchronous JavaScript: Choosing the Right Asynchronous Tool

August 27, 2020
Written by
Maciej Treder
Contributor
Opinions expressed by Twilio contributors are their own
Reviewed by
AJ Saulsberry
Contributor
Opinions expressed by Twilio contributors are their own

async-js-choosing.png

Since its beginning as a primitive, unstandardized scripting language for web browsers, JavaScript has gained many features that better equip it for handling common programming tasks. Some of the most important improvements are in the area of handling asynchronous events like form submission, user interface interaction, and media management.

The growth of server-side JavaScript enabled by Node.js has substantially expanded the range of project types and business challenges JavaScript can successfully address, adding new requirements for asynchronous processing. The popularity of web services as a design paradigm and REST APIs as an interaction standard have both added to the scope of asynchronous tasks for the language.

The Node package management system, npm, makes it possible to easily integrate capabilities provided in open source libraries. Packages for asynchronous tasks have been some of the most successful of these, adding programming paradigms to JavaScript that were unimagined in the early days of the language.

With this surfeit of tools developers can sometimes be left wondering which tool is best suited to a specific task. Programming challenges don’t always fit neatly into the abstract patterns described in documentation, so it’s helpful to have a way of understanding the strengths and weaknesses of each tool, and a way of processing the questions that will lead to the right choice.

This post provides a summary of the four principal asynchronous techniques in JavaScript programming, identifying the common use cases for each technique and the pros and cons of each tool. Each summary includes links to in-depth information and tutorials provided in other posts in this Asynchronous JavaScript series.

This post also provides a decision tree you can use to identify the asynchronous tool that’s best suited to a specific task. By answering a few straightforward questions about your programming task you can quickly find a recommendation. You’ll also learn more about the factors that determine the suitability of each tool to specific programming challenges.

It’s important to understand that while the JavaScript keywords, object types, and libraries that provide asynchronous functionality are tools in your programming workshop, they’re also the basis for programming techniques. Each tool is best wielded in specific ways, so understanding the best ways to use these technologies is as important as understanding how they work.

Callbacks

A Callback is a function passed to another function as an argument. The function receiving the Callback executes code in the background and uses the Callback function to notify the calling function when its work is done.

Typical tasks for callbacks are reading a file from disk or performing a REST API call. Callbacks can be also used to listen for events such as user interface interaction and changes in the file system.

Although using Callbacks in new code isn’t popular, they’re still a part of many foundational JavaScript libraries.

Here’s the general form of the code for a Callback:

function callback(success, error) {
   if (!error) {
       console.log(success);
   }
}
asynchronousTask(callback);

Callbacks provide listeners for an event or task in a very efficient and convenient way. A callback can be executed whenever an event occurs: it’s repeatable, an important distinction from other asynchronous tools.

The downside of using Callbacks is that they introduce tight coupling between the event emitter code and the event listener code. Tight coupling makes it impossible to add new logic to an instantiated callback, you can only set up another one and re-execute the task.

function callback(success, error) {
   if (!error) {
       console.log(success);
   }
}

function otherCallback(succes, error) {
   if(success) {
       // do something other
   }
}

asynchronousTask(callback);
asynchronousTask(otherCallback);

Callback selection summary

Typical use cases

Pros

  • Quick and efficient

Cons

  • Tight coupling
  • Unreadable in long programs due to the Pyramid of Doom

Further reading

Promises

After their introduction in 2015, Promises quickly replaced Callback functions as the preferred programming style for handling asynchronous calls. A Promise object represents an action which will be finalized in the future. As long as the action represented by a Promise is not finalized, the Promise is in a pending state. Once the action finalizes, Promise is settled, either by being fulfilled or rejected.

Here’s the general form of a Promise in use:

function asynchronousTask() {
   return new Promise(resolve =>{
       let results = doSomeAsynchronousAction();
       resolve(results);
   });
}

let taskResult = asynchronousTask();

taskResult.then(result => {
   //do something;
});

taskResult.then(result => {
   //do something other
});

Promises have a few advantages over Callbacks: They don’t force developers to couple the listener with the event. They can have multiple listeners. They can be chained together to introduce a series of asynchronous actions that depend on each other.

Unfortunately, Promises are single-action entities. Once a Promise is settled its lifecycle is finished. Because of that, they are useless for repeatable actions like mouse events or listening to a WebSocket.

A Callback can be transformed into a Promise by wrapping the asynchronous function in a Promise object, and passing the resolve function reference as a callback function:

const promise = new Promise(resolve => {
    asynchronousTask(resolve);
});

Promises selection summary

Typical use case 

  • Performing REST API calls

Pros

  • Loose coupling
  • Easy to manipulate emitted values
  • Easy to add new listeners
  • Chaining

Cons 

  • Single action mechanism

Further reading

The async and await keywords

The async and await keywords provided a substantial improvement in JavaScript’s asynchronous programming capabilities when they were introduced in 2017.

The async keyword modifies a function to return a Promise, an object representing the eventual completion or failure of an asynchronous operation. When the Promise is fulfilled by being successfully resolved, it emits the value which the function would return if it was synchronous.

Here’s the general form of an async function and an equivalent function that returns a Promise without using async:

async function myFunction1() {
   return 1;
}

function myFunction2() {
   return new Promise(resolve => {
      resolve(1);
   });
}

The await keyword can only be used inside the async function and with a Promise object. It forces the JavaScript runtime environment to pause code execution and wait for the Promise to be settled. If the Promise is fulfilled, the resolved value can be assigned to a constant or variable, like this:

async function myFunction3() {
      const someValue = await myFunction1(); 
      const anotherValue = await myFunction2();
}

If a Promise is rejected, an error is thrown. An error thrown by a Promise preceded by an await keyword can be caught and handled using the try…catch construction.

Here’s the general form of an anonymous async function with await-ed function calls inside a try…catch block:

async function myFunction4() {
   return 1;
}

function myFunction5(param) {
   return Promise.resolve(param * param);
}

(async () => {
   try {
     const value = await myFunction4();
     const power = await myFunction5(value);
  } catch(error) {
     console.log("error catched");
  }

})();

Using async and await is a perfect fit for scenarios when information obtained asynchronously is used to perform the next operation.

Unfortunately, serious performance problems, like blocking execution of one Promise while waiting for another Promise to resolve, can occur when async and await aren’t used wisely. Here’s an example:

function longPromise() {
   return new Promise(resolve => {
       setTimeout(() => resolve(), 2000);
   });
}

await longPromise();
await longPromise();

This code would take a long time to execute and no efficiency would be gained by using the await keyword.

async and await selection summary

Typical use case

  • Processing a dependent asynchronous operations like a series of REST API calls

Pros

  • Loose coupling
  • Code readability

Cons

  • May lead to performance pitfalls if not used wisely

Further reading

RxJS Observables

RxJS Observables are part of the widely-used and robust RxJS library, which is the JavaScript implementation of the ReactiveX programming paradigm. RxJS is so widely used, including as part of the Angular framework, that it should be considered on the same plane as Callbacks, Promises, and async…await.

RxJS Observables can represent repeatable event emitters, such as user interface interactions, like Callbacks, as well as single-action events, such as REST API calls, like Promises. They don’t introduce tight coupling between event emitter and listener.

You can see an example of an Observable in the snippet below:

import { Subject } from 'rxjs';
import { map } from 'rxjs/operators';


const stream$ = new Subject();

let i = 0;
setInterval(() => {
   stream$.next(i++);
}, 1000);
stream$.subscribe(console.log); //prints 0 1 2 3 4 …
stream$.pipe(map(val => val*2)).subscribe(console.log); //another subscription prints 0 2 4 6 8 ...

The above code introduces the stream$ Subject, which is a type of Observable that allows values to be multicast to many Observers. In the next line there is a setInterval mechanism, which emits a natural number every second through the stream$ Observable. At the very end of the code a subscription to the Observable is set up and the emitted data is sent to the console.

In addition to loose coupling and repeatability, there are a number of operators that may be applied to Observables to modify the values emitted by Observables using the .pipe() method.

Another benefit of Observables is that they are designed to interact with Promises and Callbacks. You can easily switch from one technique to another using methods:

  • toPromise – creates a Promise object that resolves when an underlying Observable completes, and releases the last value emitted by the Observable
  • from – creates an Observable that emits when the underlying Promise resolves, and closes itself once the value is emitted
  • bindCallback – converts a Callback into an Observable 

Unfortunately, ReactiveX concepts aren't easy to learn. Reactive Programming is a relatively new paradigm in software development. For simple tasks, it’s easier to stay with Callbacks or Promises.

RxJS selection summary

Typical use case(s)

  • Interacting with WebSockets
  • Responding to Push notifications
  • Implementing DOM events listeners
  • Performing REST API calls

Pros

  • Loose coupling
  • Repeatability
  • Plenty of operators
  • Compatibility with Promises

Cons

  • Hard to learn

Further reading

The Asynchronous JavaScript decision tree: finding the right tool

As you can see from the typical scenarios in each of the preceding sections, asynchronous tools have overlapping use cases. How do you decide which one to use?

The diagram below can help you pick the right tool. Follow the same color to the logical end of the path. The black path can be chosen from either the blue or green paths.

Asynchronous JavaScript Decision Tree

Asynchronous tool selection case studies

Some case study examples can help you gain experience and confidence with choosing an asynchronous tool. Here are three scenarios that demonstrate the decision flow in practical examples.

Scenario 1: An application is intermittently notified through a push notification that new configuration information is available and calls a REST API to obtain the information.

Emitter: push notification

  1. Is it repeatable? Yes.
  2. Does it react to multiple emissions? Yes.
  3. Can it have many listeners? No: Follow the Callback path.
  4. Does it fire other actions? Yes.
  5. Will it emit data multiple times? Yes.
  6. Use RxJS Observable with Operators.

The code might look like this:

import {Observable} from 'rxjs';
import {from} from 'rxjs/operators';

function connectToPush(): Observable {
   return observableOfNotifications;
}

function getNewConfiguration(version) {
   return fetch('http://version.server.com/' + version);
}

const notifications$ = connectToPush();

notifications$.pipe(
   filter(notification => notification.receiver == isItForMe),
   flatMap(notification => {
       return from(getNewConfiguration(notification.version));
   })
).subscribe();

Scenario 2: Confirm a WebSocket connection by reacting to the first message:

Emitter: WebSocket

  1. Is it repeatable? Yes.
  2. Does it react to multiple emissions? No: follow the Promise path.
  3. Does it fire other actions? No.
  4. Use Promises.

The code might look like:

const connected = new Promise(resolve) => {
   listenSocket(onMessage => {
       resolve(onMessage)
   });
};

connected.then(_ => console.log('Connected and received first message'));

Scenario 3: Perform a set of REST API calls depending on each other. Data returned by the previous call is used to perform the next one.

Emitter: REST API response

  1. Is it repeatable? No: follow the Promise path.
  2. Does it fire other actions? Yes.
  3. Use Promises + async and await

The code might look like this:

const response1 = await fetch('someUrl.com/data');
const response2 = await fetch('someUrl.com/' + response2.id);

console.log(response2);

Case study projects from the Asynchronous JavaScript series

You don’t need to stick with the decision tree. Sometimes each of the asynchronous techniques can be applied to achieve the goal and the choice depends on your preference.

The previous posts in the Asynchronous JavaScript series each use ones of the asynchronous tools to answer the same question “What is the best movie by Quentin Tarantino, based on reviews?”

The companion repository for the Asynchronous JavaScript series contains a Node.js project for each approach. You can study and run the projects by cloning the repository using the following command-line instructions:

git clone https://github.com/maciejtreder/asynchronous-javascript.git
cd asynchronous-javascript
npm install
cd async-await

For each technique, refer to the the following file:

  • Callbacks: organizedCallbacks.js
  • Promises: promises/fetch.js
  • async and await: async-await/fetch.js
  • RxJS Observables: rxjs/rx-http-request.js

Run the programs with the following commands:

node -r esm organizedCallbacks.js
node -r esm promises/fetch.js
node -r esm async-await/fetch.js
node -r esm rxjs/rx-http-request.js

For more information on each technique, see the associated post in the Asynchronous JavaScript series. There’s a link to each one in the Additional resources section.

Summary

In this post you saw a comparison of four techniques for working with asynchronous tasks in JavaScript: Callbacks, Promises, async and await, and RxJS Observables. You’ve seen how they compare to each other and the kinds of programming scenarios to which they’re best suited. By following the decision tree you’ve gained some experience determining which technique is best for specific programming situations.

Additional resources

The following posts are the previous steps in this series of posts walking you through the various aspects of asynchronous JavaScript:

Asynchronous JavaScript: Understanding Callbacks – Learn the fundamentals of asynchronous processing, including the event loop and callback queue.

Asynchronous JavaScript: Introduction to JavaScript Promises – Learn how Promises work and how to use them in your own projects.

Asynchronous JavaScript: Advanced Promises with Node.js – Learn advanced features of Promises and how to use them with the Node.js runtime engine.

Asynchronous JavaScript: Introducing ReactiveX and RxJS Observables – Learn to use RxJS, the JavaScript implementation of the ReactiveX framework.


Asynchronous JavaScript: Using RxJS Observables with REST APIs in Node.js – Learn to use Observables with REST APIs, one of their primary applications.

There are also 3rd-party resources that are essential references for JavaScript developers. Here are a few:

MDN web docs: Javascript – The Mozilla Developer Network provides a comprehensive JavaScript reference site, with tutorials and reference information.

Node.js Docs – If you’re writing server-side JavaScript, the Node.js reference documentation is an essential resource.

RxJS – The site for learning resources and reference information for RxJS, a JavaScript implementation of the observer, iterator patterns along with functional programming with collections.

Which Operator do I use? – A helpful tool for choosing the best Observables operator for a desired action.

Want to have some fun while you sharpen your programming skills? Try Twilio’s video game:

TwilioQuest – Learn JavaScript and defeat the forces of legacy systems with this adventure game inspired by the 16-bit golden age.

Maciej Treder is a Senior Software Development Engineer at Akamai Technologies. He is also an international conference speaker and the author of @ng-toolkit. You can learn more about the author at https://www.maciejtreder.com. You can also contact him at: contact@maciejtreder.com or @maciejtreder on GitHub, Twitter, StackOverflow, and LinkedIn.

Gabriela Rogowska contributed to this post.