4 Ways to Handle Async Operations in Javascript

4 Ways to Handle Async Operations in Javascript

Callbacks, Promises, Async/Await, and Observables

Mohamed Mayallo's photo
Mohamed Mayallo
·Feb 19, 2022·

10 min read

Table of contents

Introduction

In synchronous programming, one task can run at a time and every single line of code blocks the next one. On the other hand in asynchronous programming, operations like reading from a file or performing an API call can be launched in the background which drastically improves the app performance.

However, Javascript is a single-threaded programming language, it has the asynchronousity and non-blocking nature in which long network requests can be performed without blocking the main thread.

But how can we handle the asynchronous nature of Javascript? In this post, we will explore four ways.

Callbacks

In asynchronous operations, what we need is to get notified when the asynchronous operation completes. Callbacks are the simplest mechanism to do that. It is a function that is passed to another function to be invoked when the asynchronous operation completes.

Javascript is the ideal environment for callbacks because of two features it has:

  • In Javascript, functions are first-class objects which means they can be assigned to variables, passed as an argument, or returned from another function.
  • Javascript has Closures in which the function can retain its context and state regardless of when or where it is invoked.

Points to note when dealing with Callbacks

  1. One of the worst situations you have is if your function runs synchronously under certain conditions and asynchronously under others. Take a look at this example:

     function unPredictableBehavior(runSync, cb) {
       console.log('START');
       if (runSync) {
         cb(); // Runs synchronously
       } else {
         setTimeout(() => {
           cb(); // Runs asynchronously
         }, 100);
       }
       console.log('END');
     }
     unPredictableBehavior(true, () => console.log('CALLBACK'));
     // START
     // CALLBACK
     // END
     unPredictableBehavior(false, () => console.log('CALLBACK'));
     // START
     // END
     // CALLBACK
    

    As you can see, this example is very difficult to debug or to predict its behavior. As Callbacks can be used with sync or async operations so you have to make sure that your code does not have mixed synchronous/asynchronous behaviors.

  2. Throwing errors in an async callback would make the error jump up in the event loop which makes the program exit in non-zero exit code. So to propagate an error in async callback in the right way, you should pass this error to the next callback in the chain not throwing it or returning it.

     const fs = require('fs');
     function read (filename, callback) {
         fs.readFile(filename, 'utf8', (err, data) => {
             if (err) {
                 // return err; // Don't return the error in the callback
                 // throw err; // Don't throw the error in the callback
                 return callback(err); // The right way
             }
             return callback(null, data); // Nodejs error handling convention. First argument is the propagating error.
         });
     }
     const processData = function(err, data) {
             console.log('PROCESSING');
     }
     read('file.txt', processData);
    
  3. You can follow these practices to organize your callbacks as possible. Look at the previous example and match these points:
    • Return from the callback as early as possible.
    • Name your callback instead of using the inline style.
    • Modularize your code and use as reusable components as possible.

Pros:

  • Simple approach.
  • No need for transpilers.

Cons:

  • It is easy to fall into the Callback Hell in which code grows horizontally rather than vertically which makes it error-prone and very difficult to read and maintain.
  • Nested callbacks can lead to the overlapping of the variable names.
  • Hard error handling. You can easily forget to propagate the error to the next callback and if you forget to propagate a sync operation error it will easily crash your app.
  • You can easily fall into a situation in which your code can run synchronously under certain conditions and asynchronously under others.

Promises

Promises are presented in Javascript as a part of the ES6 standard. It represents a big step to provide a great alternative to Callbacks.

A promise is an object that contains the async operation result or error. A promise is said to be pending if it isn’t yet complete (fulfilled or rejected) and said to be settled if it is complete (fulfilled or rejected).

To receive the fulfillment or the rejection from an asynchronous operation, you have to use .then method of the promise as follows:

fetch('any-url')
    .then(onFulfilled, onRejected)
    .then(onFulfilled, onRejected);

onFulfilled is a callback that will receive the fulfilled value and onRejected is another callback that will receive the error reason if any.

Points to note when dealing with Promises

  1. The then method returns another promise synchronously which enables us to chain many promises and easily aggregate many asynchronous operations into many levels.

     asyncProcess()
         .then(asyncProcess2)
         .then(syncAggregatorProcess)
         .then(asyncProcess3);
    
  2. If we don’t define the onFulfilled or onRejected handlers, the fulfillment value or the rejection reason will propagate automatically to the next level of then promise. This behavior enables us to automatically propagate any error across the whole chain of promises. In addition, you can use the throw statement in any handler in contrary of Callbacks which makes the Promise rejects automatically and this means the thrown exception will automatically propagate across the whole promises chain.

    asyncProcess()
        .then(() => {
            throw new Error('Error');
        })
        .then()
        .catch(err => {
            // Catch any error from the chain here
        });
    
  3. onFulfilled and onRejected handlers are guaranteed to run asynchronously even if the Promise is already settled at the time then is called. This behavior can protect us from the unpredictable behavior of mixed sync/async code that can be easy to fall into with Callbacks as we saw.

    const instantPromise = Promise.resolve(3);
    instantPromise
        .then((res) => { // `then` method will run asynchronously however Promise completes instantly
            console.log(res);
        });
    

Pros:

  • Promises significantly improve code readability and maintainability and mitigate the Callback Hell.
  • The elegant way of error handling as we saw.
  • No need for transpilers on major browsers.
  • Protecting our code from unpredictable behavior like Callbacks.

Cons:

  • When using Promises with sequential operations, you are forced to use many thens which means many functions for every then which may be so much for everyday programming use.

Async/Await

Over time Javascript community has tried to reduce the asynchronous operations complexity without sacrificing the benefits. The Async/Await is considered the peak of that endeavor and the recommended approach when dealing with asynchronous operations. It is added to Javascript in the ES2017 standard. And it is a superset of Promises and Generators.

The async function is a special kind of function in which you can use await expression to pause the execution of an asynchronous operation until it resolves.

async function apiCall() {
    const fulfilledVal1 = await asyncOperation1();
    const fulfilledVal2 = await asyncOperation2(fulfilledVal1);
    return fulfilledVal2;
}

Points to note when dealing with Promises

  1. The async function always returns a Promise regardless of the resolved value type which protects us from the unpredictable code with mixed sync/async behavior.
  2. Unlike Promises, with async/await we can use try/catch to make it work seamlessly with both synchronous throws and asynchronous Promise rejections.

     const asyncError = () => Promise.reject(new Error('ASYNC ERROR'));
     async function apiCall(syncError) {
         try {
             if (syncError) {
                 throw new Error('SYNC ERROR');
             }
             await asyncError();
         } catch (err) {
             console.log(err.message);
         }
     }
     apiCall(true); // SYNC ERROR
     apiCall(false); // ASYNC ERROR
    
  3. Unfortunately, we can’t await for multiple asynchronous operations simultaneously. But as a solution for this, we can use the Promise.all() static method to resolve multiple concurrent promises.

     const resolvedRes = await Promise.all([Proimse1, Promise2, Promise3]);
    

Pros:

  • The significant improvement of code readability and maintainability. As we saw, writing a sequence of asynchronous operations is easy as writing synchronous code. No extra nesting is required.
  • The elegant way of error handling. Now we can use try/catch block to work seamlessly with both synchronous throws and asynchronous rejections.
  • Avoid unpredictable code with mixed sync/async behaviors.

Cons:

  • In fact, within async functions, you may end up with a huge function that contains several functions glued together into one. In turn, this function performs many tasks which may conflict with the Single Responsibility Principle.
  • The transpiled version of async/await is very huge if compared with the promise version. Take a look at the following screenshots. Screenshot_3.png Screenshot_4.png

ReactiveX

ReactiveX programming is a paradigm that considers every bit of data as a stream you can listen to and react to accordingly. It operates on both synchronous and asynchronous streams by applying the following practices:

  • Observer Pattern: Observable has at least one Observer that will notify it automatically with any state changes and this model is called the Push Model.
  • Iterator Pattern: In fact, In Javascript, any iterator must support the next() method which is supported in Observers API to get the next stream of data and this model is called the Pull Model.
  • Functional Programming: ReactiveX libraries include operators which are nothing than pure functions that take inputs/Observables and return new Observables which depend only on these inputs so they are chainable or pipeable.

Observable is an object that takes a stream of data and emits events over time to react accordingly. There is a talk to add it to the ECMAScript standard and its proposal is here. Till now it is not part of the ECMAScript standard so to use it, you have to use a third-party library and the well-known Reactive Extension in Javascript is RxJs.

Take a look at the following example in which we create a new Observable and match it with the previous points:

import { Observable } from "rxjs";
import { map, filter } from "rxjs/operators";
const observer = {
  next: (res) => console.log(res),
  error: (err) => console.log(err),
  complete: () => console.log('COMPLETED')
};
const observable$ = new Observable(subscriber => { // $ is a convention used for Observable naming
  subscriber.next(1);
  subscriber.next(2);
  subscriber.next(3);
  subscriber.next(4);
  subscriber.next(5);
  subscriber.complete();
});
const subscription = observable$.pipe(
  map(n => n * n),
  filter(n => n % 2 === 0)
).subscribe(observer);
subscription.unsubscribe();

We can also handle API calls operations like this:

import { fromFetch } from "rxjs/fetch";
import { mergeMap } from "rxjs/operators";
fromFetch('https://jsonplaceholder.typicode.com/posts/1')
  .pipe(
    mergeMap(data => data.json())
  ).subscribe(data => console.log(data));

Points to note when dealing with Observables

  1. Observable is lazy which means it does not do anything unless you subscribe to it. On the other hand, Promise is eager which means once it is created it will resolve or reject.
  2. You should unsubscribe from any subscribed Observable to avoid any memory leak.
  3. You can create Observable from a Promise with fromPromise function and create Observable from based-Callback API with bindCallback or bindNodeCallback.
  4. Observables can be Unicast or Multicast. On the other hand, Promises are always Multicast. To know what is the difference between Unicast and Multicast let me first explain what is the difference between Hot Observables and Cold Observables. An Observable is Cold if the stream is created during the subscription. This means that every observer will get a unique communication channel so will get its unique result of data (Unicast or you can call “unique-cast” to remember).

     const cold = new Observable(subscriber => {
       const random = Math.random();
       subscriber.next(random);
     });
     cold.subscribe(res => console.log(res)); // 0.6105514567126951
     cold.subscribe(res => console.log(res)); // 0.11171313865866939
     cold.subscribe(res => console.log(res)); // 0.3808628177873419
    

    On the other hand, An Observable is Hot if the stream is created outside the subscription. This means that every subscribed observer will get the same result of data (Multicast).

     const random = Math.random();
     const hot = new Observable(subscriber => {
       subscriber.next(random);
     });
     hot.subscribe(res => console.log(res)); // 0.4606147263760665
     hot.subscribe(res => console.log(res)); // 0.4606147263760665
     hot.subscribe(res => console.log(res)); // 0.4606147263760665
    

    So Unicast is a one-to-one communication process in which every observer will get its unique communication channel and Multicast is a one-to-many communication process in which all observers will share the same data.

    Promises are multicast because every resolver will share the same data as Hot Observables.

     const random = Math.random();
     const prom = Promise.resolve(random);
     prom.then(res => console.log(res)); // 0.35813662853379356
     prom.then(res => console.log(res)); // 0.35813662853379356
     prom.then(res => console.log(res)); // 0.35813662853379356
    

Pros:

  • An Observable can emit multiple values over time which makes it a perfect fit when dealing with events, WebSocket, and repetitive REST API calls.
  • The loosely coupling between Observable and its Observers in which the Observable will notify its Observers with any change without direct dependency.
  • Observables can be Unicast or Multicast as well based on your use.
  • The extremely powerful operators to filter, transform or compose Observables.
  • Observables are cancelable in contrary of Promises.
  • It is easy to refactor Promises-based or Callbacks-based code to Observables.

Cons:

  • Observables have a steep learning curve.
  • Till now you have to add a third-party library in order to use it.
  • It is easy to forget unsubscribing from an Observable which leads to a memory leak.

Conclusion

So far we have explored four approaches to handle asynchronous operations and all of them can get things done, but what approach should you use? The answer to this question is fully dependent on you, you have to fully understand every approach trade-offs and the points of power. Eventually, you can decide the more fit based on your situation.

Resources

Nodejs Design Patterns 3rd edition book.

async/await: It’s Good and Bad

JavaScript Promises vs. RxJS Observables

Asynchronous JavaScript: Using RxJS Observables with REST APIs in Node.js

Asynchronous JavaScript: Introducing ReactiveX and RxJS Observables

Hot vs Cold Observables

 
Share this