Unlocking the Magic of JavaScript Asynchronous Programming: Mastering Callbacks, Promises, and Async/Await

Unlocking the Magic of JavaScript Asynchronous Programming: Mastering Callbacks, Promises, and Async/Await

JavaScript
Fix bugs faster! Log Collection Made Easy
START NOW!

“A single-threaded, non-blocking, asynchronous, concurrent programming language with many flexibilities”.

This is how freeCodeCamp defines JavaScript.

Sounds great, right? Well, read it again.

JavaScript is supposed to be single-threaded and asynchronous simultaneously. Which, when you think about it, sounds like a contradiction.

But don’t worry, in this article we’re going to go under the hood, looking specifically at the possibilities of asynchronous programming in JavaScript with Callback, Promises, and Async/Await, so you can see how the two terms interact and co-exist with one another.

The Synchronous Part of JavaScript

In JavaScript, functions are first-class citizens.

You can create a function, update it, pass it as an argument to another function, return it from a function, and assign a function to a variable.

In fact, the JavaScript programming language would be unusable without the existence of functions; we assign many repetitive tasks to a function and they execute on demand.

//Define a function
function doSomething() {
  // All tasks to do
}

// Invoke or Execute the function
doSomething();

JavaScript executes the function sequentially, line by line, and its engine maintains a stack data structure called Function Execution Stack(also known as Call Stack) to track the execution of the current function.

When the JavaScript engine invokes a function, it adds the function to the stack to start the execution. If that function calls another function within it, the second function also gets added to the stack, and executes.

Following the execution of the second function, the engine removes it from the stack and continues the execution of the first function. Once the first function has executed, the engine also removes that from the stack. This continues until the stack becomes empty.

The Function Execution Stack or Call Stack

Everything that happens inside the Call Stack is sequential, and that makes JavaScript synchronous. The main thread of the JavaScript engine takes care of everything in the call stack first, before it starts looking into other places like queues and Web APIs (don’t worry, we’ll dig into these in a while).

The Asynchronous Part of JavaScript

Now, let’s understand how the term ‘asynchronous’ applies to JavaScript.

In general, asynchronous refers to things that don’t occur at the same time. The synchronous behaviour of JavaScript is fine, but it may cause issues when users want dynamic operations, such as fetching data from the server or writing into the file system.

We don’t want the JavaScript engine to suspend its execution of the sequential code when it needs to take care of the dynamic behaviours. In fact, we want the JavaScript engine to execute the code asynchronously.

The asynchronous behaviors in JavaScript can be broken down into two factors:

  • Browser APIs or Web APIs: These APIs include methods like setInterval and setTimeout, and offer asynchronous behaviours.
  • Promises: This is an in-built JavaScript object, and helps us deal with asynchronous calls.

In this article, we’ll focus on the latter factor to understand JavaScript promises and how they work. But before that, let’s learn about the predecessor of JavaScript promises – that is, the Callback functions.

What is a Callback Function, and How does it Work?

We’ve already noted that JavaScript functions are a crucial aspect of the JavaScript language and a function can take another function as an argument. This is a powerful feature that was introduced to handle certain operations that may occur at a later point in time, i.e. asynchronously.

Now let’s take a look at the following code snippet.

We have a function, doSomething(), which takes a function, doSomethingLater(), as an argument and invokes it based on certain conditions. Here, doSomething() is the Caller Function and doSomethingLater() is the Callback Function.

Later we’ll invoke the caller function, passing the callback function as an argument.

function doSomething(doSomethingLater) {
 // Code doing something
 // More code...
 
 if(thatThingHappen) {
	 doSomethingLater()
 } else {
	 // Continue with whatever...
 }
}

// Invoke doSomething() with a callback function
doSomething(function() {
	console.log('This is a callback function');
});

This callback function methodology was widely used to perform asynchronous calls in JavaScript. Take this example:

function orderBook(type, name, callback) {
    console.log('Book ordered...');
    console.log('Book is for preparation');
    setTimeout(function () {
        let msg = `Your ${type} ${name} Book is ready to dispatch!`;
        callback(msg);
    }, 3000);
}

The orderBook method takes three arguments:

  1. Type of the book.
  2. Name.
  3. A callback function.

We have simulated the asynchronous behavior using the Web API function setTimeout, and we invoke the callback function after a delay of 3 seconds.

In the following examples, we will be using JavaScript’s console log. If you want to learn the best tips and tricks on how to use it like an expert JavaScript developer, you can check out our article:

Discover the Hidden Potential: Advanced JavaScript Console Log for Developers

Now, take a look at the invocation of the caller function.

orderBook('Science Fiction', 'Blind Spots', function(message){
	console.log(message);
});

Here, we want to order a science fiction book named Blind Spots and we’ll get a message from the callback function once the book is ready to dispatch. Here is the output.

The output of the book ordering app

So we’ve seen that the JavaScript callback function is well-suited to handle the asynchronous call’s success or error cases, and report it to the end users. But it also introduces problems as your application grows.

When you have to make multiple asynchronous calls and handle them using the callback functions, this will lead to a situation where you will invoke one callback function inside another, and eventually creates a Callback Hell – or, as it’s more commonly known, a Callback Pyramid.

Callback Hell – Let’s not get into it.

A code like the one above is tricky to read, difficult to debug, and may introduce further errors.

But don’t panic: there are ways out of the callback hell, and one of the most effective is by using JavaScript Promises.

What are JavaScript Promises?

JavaScript promises are the special objects that help deal with the asynchronous operations. You can create a promise using the constructor syntax:

const promise = new Promise(function(resolve, reject) {
  // Code to execute
});

The Promise constructor function takes a function as an argument, and this is called the executor function.

// Executor function
function(resolve, reject) {
    // Your logic goes here...
}

The code inside the executor function runs automatically when the promise is created.

The executor function takes two arguments, resolve and reject. These are the callback functions provided by the JavaScript language internally.

The executor function call either resolves or rejects callback functions. resolve handles the successful completion of tasks, while reject handles cases where there is an error and the task is incomplete.

The Promise constructor returns a promise object, with two important properties:

  • state: The state property can have the following values:
    • pending: When the executor function starts the execution.
    • fulfilled: When the promise is resolved.
    • rejected: When the promise is rejected.
  • result: The result property has the following values:
    • value: When resolve(value) is called
    • error: When reject(error) is called
    • undefined: When the state value is pending.

You cannot access these properties in your code directly. However, you’ll be able to debug them using debugger tools.

Inspecting promises with the debugger devtool

JavaScript Promises – resolve and reject

Here’s an example of a JavaScript promise that resolves immediately with the message “task completed”.

let promise = new Promise(function(resolve, reject) {
    resolve("task complete");
});

Now here’s an example of a promise that rejects immediately with the message “error: task incomplete”.

let promise = new Promise(function(resolve, reject) {
    reject(new Error('Error: task incomplete'));
});

Handling Promises Using the Handler Functions

So far we’ve learned about the executor function, where we write the code to make the asynchronous call and resolve/reject based on the outcome.

But what happens when we resolve or reject from an executor function? Who uses it? What do we do with the promise object after that? How do we handle it in our code when resolve/reject takes place?

So many questions, right?? Let’s try to get answers using the diagram below:

Promise executor and consumer functions

When the executor function is done with its tasks and performs a resolve (for success) or reject (for error), the consumer function receives a notification using the handler methods like .then(), .catch(), and .finally().

We invoke these methods on the promise object.

promise.then(
  (result) => { 
     console.log(result);
  },
  (error) => { 
     console.log(error);
  }
);

Here’s an example using the .catch() method:

new Promise((resolve, ¸) => {
  reject("Error: Task incomplete!");
}).catch((error) => console.log(error));

Promise Chain – A Better Alternative than Callback Functions

The .then() handler method returns a new promise object. This means you can now call another .then() method on the returned promise object.

Also, the first .then() method returns a value that JavaScript passes to the next .then() method, and so on up to a Promise Chain.

A Promise Chain

Let’s dig into this with an example. Read the following code snippet carefully:

let promise = getPromise(ALL_BOOKS_URL);

promise.then(result => {
    let oneBook = JSON.parse(result).results[0].url;
    return oneBook;
}).then(oneBookURL => {
    console.log(oneBookURL);
}).catch(error => {
    console.log('In the catch', error);
});
  • Assume the getPromise() method returns a promise.
  • Now we have to handle this promise. So, we call the .then() handler method, which receives the result value as the callback.
  • Next, we parse the value, extract the first URL from the response and return.
  • When we return something from the .then() method, it returns a promise. So, we can again handle this using another .then() method. Thus a promise chain has formed.

If we had to handle the same with the Callback function we learned earlier, we would have created a callback pyramid. The above code, with promises, is much more readable and easier to debug.

A Further, Easier Way: Async/Await Keywords

JavaScript provides two keywords, async and await, to make the usages of promises even easier.

These keywords are syntactic sugar on the traditional JavaScript promises to help developers provide a better user experience while writing less code.

  • We use the async keyword to return a promise.
  • We use the await keyword to handle a promise.

When a function performs the asynchronous operation, we add the async keyword as shown in the example below. The JavaScript engine will know that this function may take a while to get us the response, and may result in a value or error.

async function fetchUserDetails(userId) {
    // pretend we make an asynchronous call
   // and return the user details
   return {'name': 'Tapas', 'likes': ['movies', 'teaching']};
}

When we invoke the fetchUserDetails() function, it returns a promise like this:

The output of executing an async function

Because the above function returns a promise, we need to handle it. Here comes the await keyword.

const user = await fetchUserDetails();
console.log(user)

When we invoked the function, we have used the await keyword. It will make the execution of the async function wait until the promise is settled, either with a value (resolve) or with an error (reject).

The returned user object after the resolved promise

Question for you: How would you handle the above code of execution with the plain JavaScript promises? You may be doing something like this:

const userPromise = fetchUserDetails(123);

userPromise.then(function(user) {
    console.log(user);
});

As you’ll see yourself, the async/await keyword certainly makes the code easier to read than the plain JavaScript promises.

A word of caution here, though: you cannot use the await keyword with a regular, non-async function. You will get an error when you try doing so.

Before We Go…

We hope you’ve found this article insightful and it gives you the walkthrough and comparison between callback functions, promises, and the async/await keywords. As a web developer, you must know all these usages even though you use more of async/await in your code than the other counterparts.

JavaScript asynchronous programming is an interesting subject and, with practice, you can master it really well to face interviews with confidence. So make sure you do lots of practice using callback, promises, and async/await examples. As you are interested in asynchronous JavaScript, you might also find interest in learning about MQTT, if this is the case you can check our article about using MQTT in an Angular app.

To help you with that, here is a repository with many quizzes and examples of asynchronous programming that you may want to try out:

GitHub – atapas/promise-interview-ready: Learn JavaScript Promises in a new way. This repository contains all the source code and examples that make you ready with promises, especially for your interviews 😉.

Also, if you are keen to explore this subject in greater detail, take a look at this playlist on YouTube.

Expect the Unexpected! Debug Faster with Bugfender
START FOR FREE

Trusted By

/assets/images/svg/customers/cool/websummit.svg/assets/images/svg/customers/highprofile/schneider_electric.svg/assets/images/svg/customers/projects/porsche.svg/assets/images/svg/customers/highprofile/rakuten.svg/assets/images/svg/customers/highprofile/ford.svg/assets/images/svg/customers/highprofile/deloitte.svg/assets/images/svg/customers/cool/domestika.svg/assets/images/svg/customers/projects/taxify.svg

Already Trusted by Thousands

Bugfender is the best remote logger for mobile and web apps.

Get Started for Free, No Credit Card Required