DEV Community

Cover image for Asynchronous JavaScript: The TL;DR Version You'll Always Recall
Aditya Bhattad
Aditya Bhattad

Posted on

Asynchronous JavaScript: The TL;DR Version You'll Always Recall

I've noticed that async JavaScript is a topic of importance in many frontend and full-stack interviews. So rather than having to open docs and other 100s of resources before each interview or whenever I need to implement it, I decided to create a comprehensive resource ones and for all. The result? This blog.
In this blog post, I have included all the things I knew about async Javascript. So without a further ado, let's get started🚀

Introduction to Asynchronous JavaScript

To understand asynchronous programming, we first need to understand synchronous programming.

Synchronous Example:

console.log("Vivek loves Javascript");
console.log("Vivek is a frontend dev");
console.log("Vivek wants to learn async Javascript");
Enter fullscreen mode Exit fullscreen mode

In this example, the browser executes each line sequentially, waiting for each console.log statement to complete before moving to the next. This approach works fine for quick operations but can cause problems with time-consuming tasks.

Take this inefficient factorial calculator:

If you input a large number and click "Check to find out", the program freezes temporarily, making the page unresponsive. This happens because JavaScript, in its basic form, is synchronous, blocking, and single-threaded language in its most basic form. When calculateFactorial is called, it occupies the single thread, preventing any other code from executing until it returns.

Making the Program Responsive

To make our program more responsive, it should:

  1. Start a long-running operation by calling a function.
  2. Have the function initiate the operation and return immediately, allowing the program to remain responsive to other events.
  3. Execute the operation in a way that doesn't block the main thread.
  4. Notify us with the result when the operation eventually completes.

Asynchronous Functions

Asynchronous functions allow a program to initiate a time-consuming task and remain responsive to other events while that task runs. The program can continue executing other code and receive the result once the task completes.

In the following sections, first we will explore how to use them, and then at the end we will take a look at how they work behind the scene.

Timeout and Interval

Let's start with the basics of async programming and build from there.

setTimeout

The setTimeout function executes a block of code once after a specified time has elapsed.

Parameters:

  1. A reference to the function to be executed.
  2. The time (in milliseconds) before the function will be executed.
  3. Optional parameters to pass to the function when executed.

Function Signature:

setTimeout(function, duration, param1, param2, ...);
Enter fullscreen mode Exit fullscreen mode

To cancel a timeout, you can use the clearTimeout() method, passing in the identifier returned by setTimeout as a parameter.

Here's how you can use clearTimeout:

const timeoutId = setTimeout(() => {
    console.log('hello');
}, 100);
clearTimeout(timeoutId);
// Expected output: (nothing)
Enter fullscreen mode Exit fullscreen mode

A more practical scenario for clearing timeouts is in React when a component gets unmounted. We can use clearTimeout to cancel the timeout used in that component, freeing up resources.

setInterval

setInterval is similar to setTimeout, with one key difference: it executes repeatedly at the specified interval, continuing indefinitely until cleared.

Example:

const intervalId = setInterval(() => {
    console.log('hello, from setInterval!');
}, 100);
clearInterval(intervalId);
Enter fullscreen mode Exit fullscreen mode

Notes:

  • Timers and intervals are not part of JavaScript itself but are implemented by the browser (client-side) and Node.js (server-side). setTimeout and setInterval are names given to this functionality in JavaScript.
    You can achieve the same effect as setInterval with a recursive setTimeout:

  • It's also possible to achieve the same effect as setInterval with a recursive setTimeout:

function run() {
    console.log("I will also run after a fixed duration of time, just like I would have if it was setInterval.");
    setTimeout(run, 100);
}
setTimeout(run, 100);
Enter fullscreen mode Exit fullscreen mode

Callback Functions

Definition and purpose

In JavaScript, functions are first-class objects, meaning they can be:

  1. Assigned to variables
  2. Passed as arguments to other functions
  3. Returned from functions This ability to pass functions as arguments is what enables callback functionality. Any function that is passed to another function is called a callback function. The function which accepts another function as an argument or returns another function is called a higher-order function.

With setTimeout and setInterval, we pass a callback to these functions, making them higher-order functions.

Take another simple example:

// This is an example of a callback function.
function greet(name) {
    console.log(`Hello, ${name}!`);
}

// This is an example of a higher-order function.
function greetVivek(greetFn) {
    greetFn("Vivek");
}

greetVivek(greet);
Enter fullscreen mode Exit fullscreen mode

In this example, greet is a callback function in the context of greetVivek. Since greetVivek takes a function as input, it is considered a higher-order function.

Synchronous vs. Asynchronous Callbacks

  • Synchronous Callback: Executes immediately, like in the example above.
  • Asynchronous Callback: Executes after an asynchronous operation completes, delaying execution until a particular time or event, example: callback passed to setTimeout.

Callback Hell

When multiple callback functions depend on the result obtained from the previous level, it can lead to deeply nested code, making it difficult to read and maintain.

Example

getData(function(a) {
    getMoreData(a, function(b) {
        getMoreData(b, function(c) {
            getMoreData(c, function(d) {
                getMoreData(d, function(e) {
                    console.log('Where the hell am I??');
                });
            });
        });
    });
});
Enter fullscreen mode Exit fullscreen mode

To solve this problem, promises were introduced, making asynchronous code easier to write and understand.

Promises

Introduction to Promises

MDN Definition:

A promise is a proxy for value not necessarily known when it is created, it allows us to associate handlers with an asynchronous actions eventual success value or failure reason.

In simple words:
A Promise is an object representing the eventual completion or failure of an asynchronous operation. It can be in one of three states:

  1. Pending: Initial state, neither fulfilled nor rejected.
  2. Fulfilled: The operation completed successfully.
  3. Rejected: The operation failed.

The eventual state of a pending promise can either be fulfilled with a value or rejected with a reason (error). When either of these states occur, the associated handlers queued up by the promise's then or catch method are called.

Example:

function buySandwich() {
  return new Promise((resolve, reject) => {
    if (Math.random() > 0.5) {
      resolve('Here is your cheese sandwich!');
    } else {
      reject(new Error('Sorry, not enough bread left.'));
    }
  });
}

buySandwich()
  .then((res) => {
    console.log(res);
    console.log("I love cheese sandwiches.");
  })
  .catch((err) => {
    console.log(err.message);
    console.log("Now I will have to cook pasta instead.");
  })
  .finally(() => {
    console.log("Let’s go for a walk!");
  });


Enter fullscreen mode Exit fullscreen mode

If the promise has already been fulfilled or rejected when a handler is attached, the handler will still be called, so there is no race condition between an asynchronous operation and its handlers being attached.

const myPromises = Promise.resolve('Trust me bro!');
myPromise.then((value)=>{
    console.log('Told, you!');
})

Enter fullscreen mode Exit fullscreen mode

Here is nice diagram from MDN to understand it better

Promises flow

Chaining Promises

Since .then() and .catch() methods both return promises, they can be chained.

This functionality makes them better alternative of callbacks.

How promises can be use instead of callbacks

Callback Example

getData(function(a) {
    getMoreData(a, function(b) {
        getMoreData(b, function(c) {
            getMoreData(c, function(d) {
                getMoreData(d, function(e) {
                    console.log('Where the hell am I??');
                });
            });
        });
    });
});
Enter fullscreen mode Exit fullscreen mode

Same function written using promises

getData()
  .then(a => getMoreData(a))
  .then(b => getMoreData(b))
  .then(c => getMoreData(c))
  .then(d => getMoreData(d))
  .then(e => {
    console.log('Here is the final result: ', e);
  })
  .catch(err => {
    console.error('Something went wrong:', err);
  });
Enter fullscreen mode Exit fullscreen mode

Much more readable this way.

Error handling with Promises

There are two ways to handle errors with promises:

  1. Passing an onRejected handler as the second argument to .then(): If we do this, the error won't be caught if it is thrown from the onFulfillment handler.
   myPromise.then(
       result => { /* handle success */ },
       error => { /* handle error */ }
   );
Enter fullscreen mode Exit fullscreen mode
  1. Passing an onRejected handler to a .catch() block: This ensures that even if the onFulfillment handler throws an error, it is caught by the .catch() and can be handled there.
   myPromise
       .then(result => { /* handle success */ })
       .catch(error => { /* handle error */ });
Enter fullscreen mode Exit fullscreen mode

The .catch() method is generally preferred as it also catches errors thrown in the .then() handlers.

Static method for promises

Promise.all()

The Promise.all() method takes an iterable of promises as input and returns a single Promise that resolves to an array of the results of the input promises. The returned promise will resolve when all of the input's promises have resolved, or if the input iterable has no promises. It rejects immediately if any of the input promises reject or if a non-promise throws an error, and will reject with the first rejection message/error.

Example

const promise1 = Promise.resolve(3);
const promise2 = 42;
const promise3 = new Promise((resolve,reject)=>{
     setTimeout(resolve,100,'foo');
})
Promise.all([promise1,promise2,promise3]).then((values)=>{
     console.log(values);
})
// expected output Array [3,42,'foo']
Enter fullscreen mode Exit fullscreen mode

Promise.allSettled()

Slight varaition of Promise.all(), Promise.allSettled() waits for all input promises to complete regardless of whether they resolve or reject. It returns a promise that resolves after all of the given promises have either resolved or rejected, with an array of objects that each describe the outcome of each promise.

Example:

const promise1 = Promise.reject("failure");
const promise2 = 42;
const promise3 = new Promise((resolve) => {
    setTimeout(resolve, 100, 'foo');
});
Promise.allSettled([promise1, promise2, promise3]).then((results) => {
    console.log(results);
});
// expected output: Array [
//   { status: "rejected", reason: "failure" },
//   { status: "fulfilled", value: 42 },
//   { status: "fulfilled", value: 'foo' }
// ]
Enter fullscreen mode Exit fullscreen mode

Promise.any()

Promise.any() takes an iterable of promises as input and returns a single Promise. This returned promise fulfills when any of the input promises fulfill, with the first fulfillment value. It rejects when all of the input's promises reject (including when an empty iterable is passed), with an AggregateError containing an array of rejection reasons.

Example

const promise1 = Promise.reject(0);
const promise2 = new Promise((resolve) => setTimeout(resolve, 100, 'quick'));
const promise3 = new Promise((resolve) => setTimeout(resolve, 500, 'slow'));

const promises = [promise1, promise2, promise3];

Promise.any(promises).then((value) => console.log(value));
// expected output: "quick"
Enter fullscreen mode Exit fullscreen mode

Promise.race()

The Promise.race() method returns a promise that fulfills or rejects as soon as one of the input promises fulfills or rejects, with the value or reason from that promise.
Example

const promise1 = new Promise((resolve,reject)=>{
     setTimeout(resolve,500,'one');
})
const promise2 = new Promise((resolve,reject)=>{
     setTimeout(resolve,100,'two');
})
Promise.race([promise1,promise2]).then((value)=>{
     console.log(value);
     // Both resolves but promise2 is faster.
})
// expected output: 'two'
Enter fullscreen mode Exit fullscreen mode

Most famous usage of Promise.race(): to implement timeouts for async function, that is if the async functions take too long we can suspend it.

function promiseWithTimeout(promise,duration){
  return Promise.race(
    [
      promise,
      new Promise((_,reject)=>{
        setTimeout(reject,duration,"Too late.")
      })
    ]
  )
}


promiseWithTimeout(new Promise((resolve,reject)=>{
  setTimeout(resolve,4000,"Success.")
}),5000).then((result)=>{
  console.log(result)
}).catch((error)=>{
  console.log(error)
})
Enter fullscreen mode Exit fullscreen mode

Async/Await

Introduction to async/await

From the above sections, it's clear that chaining promises solves the problem we had with callback hell. However, there is an even better way to handle asynchronous operations: using the async and await keywords introduced in ES2017 (ES8). These keywords allow us to write code that looks synchronous while performing asynchronous tasks behind the scenes.

Async functions

The async keyword is used to declare async functions. Async functions are instances of the AsyncFunction constructor. Unlike normal functions, async functions always return a promise.

Normal function

function greet() {return "hello"}
greet()
// expected output: hello
Enter fullscreen mode Exit fullscreen mode

Async function

async function greet() {return "hello"}
greet()
Enter fullscreen mode Exit fullscreen mode

We can also explicitly return a promise:

async function greet() {
    return Promise.resolve("hello")
}
greet()
// expected out (same for both): Promise{<fulfilled>:"hello"}
Enter fullscreen mode Exit fullscreen mode

We can use .then() to get actual result.

greet().then((res)=>{
    console.log(res);
})
// expected output: "hello"
Enter fullscreen mode Exit fullscreen mode

The real advantage of async functions is when we use them with the await keyword.

The await keyword

The await keyword can be placed in front of any async promise-based function to pause your code execution until that promise settles and returns its result. Note that the await keyword only works inside async functions, so we cannot use await inside normal functions.
Example

async function greet(){
    let promise = new Promise((resolve,reject)=>{
        setTimeout(()=>resolve("hello"),1000)
    })
    let result = await promise;
    console.log(result);
}

greet()
// expected output: "hello" (after 1 second)
Enter fullscreen mode Exit fullscreen mode

Chaining Promises vs Async/Await

Here is the same function written with promises as well as async/await:
Using Promises

getData()
    .then(a => getMoreData(a))
    .then(b => getMoreData(b))
    .then(c => getMoreData(c))
    .then(d => getMoreData(d))
    .then(e => {
        console.log('Here is the final result: ',e);
    })
    .catch(err => {
        console.error('Something went wrong:', err);
    });
Enter fullscreen mode Exit fullscreen mode

Using Async/Await

async function getData() {
    try {
        const a = await getData();
        const b = await getMoreData(a);
        const c = await getMoreData(b);
        const d = await getMoreData(c);
        const e = await getMoreData(d);
        console.log('Here is the final result: ', e);
    } catch (err) {
        console.error('Something went wrong:', err);
    }
}
Enter fullscreen mode Exit fullscreen mode

Even error handling becomes much simpler with async/await.

Sequential vs Concurrent Execution

To improve the performance of web applications, we can use all the concepts we've learned above. Normally, when making asynchronous function calls one after another, the requests are blocked by the previous request, referred to as a request "waterfall," as each request can only begin once the previous request has returned data.

Sequential Execution

// Simulate two API calls with different response times
function fetchFastData() {
  return new Promise(resolve => {
    setTimeout(() => {
      resolve("Fast data");
    }, 2000);
  });
}

function fetchSlowData() {
  return new Promise(resolve => {
    setTimeout(() => {
      resolve("Slow data");
    }, 3000);
  });
}

// Function to demonstrate sequential execution
async function fetchDataSequentially() {
  console.log("Starting to fetch data...");

  const startTime = Date.now();

  // Start both fetches concurrently
  const fastData = await fetchFastData();
  const slowData = await fetchSlowData();

  const endTime = Date.now();
  const totalTime = endTime - startTime;

  console.log(`Fast data: ${fastData}`);
  console.log(`Slow data: ${slowData}`);
  console.log(`Total time taken: ${totalTime}ms`);
}
fetchDataSequentially()
/*
expected output:
Starting to fetch data...
Fast data: Fast data
Slow data: Slow data
Total time taken: 5007ms
*/
Enter fullscreen mode Exit fullscreen mode

Visualization for sequential execution

Concurrent Execution:

async function fetchDataConcurrently() {
  console.log("Starting to fetch data...");

  const startTime = Date.now();

  // Start both fetches concurrently
  const fastDataPromise =  fetchFastData();
  const slowDataPromise =  fetchSlowData();

  // Wait for both promises to resolve
  const [fastData, slowData] = await Promise.all([fastDataPromise, slowDataPromise]);

  const endTime = Date.now();
  const totalTime = endTime - startTime;

  console.log(`Fast data: ${fastData}`);
  console.log(`Slow data: ${slowData}`);
  console.log(`Total time taken: ${totalTime}ms`);
}
/*
expected output:
Starting to fetch data...
Fast data: Fast data
Slow data: Slow data
Total time taken: 3007ms
*/
Enter fullscreen mode Exit fullscreen mode

Visualization for concurrent execution

In the concurrent execution, both requests are fired off simultaneously, and we await them using Promise.all(). As the requests are called concurrently, no request has to wait for the other, resulting in faster overall execution.

JavaScript Event Loop

Now that we have seen what promises are and how to use them, it is always good to understand how they work. As I mentioned earlier, JavaScript is a synchronous, blocking, and single-threaded language. The JavaScript engine has its own provisions to execute async code. Several different components come together to make async code execution possible.

Call Stack

The call stack is where the code executes line by line. The execution pointer starts from the top, pushing functions to be executed line by line onto the call stack and popping them out once they return.

Web APIs

These are provided by the browser in client-side JavaScript and by Node.js in server-side JavaScript. When there is any asynchronous task to be executed, it is passed to Web APIs, which are responsible for executing them. This offloading of asynchronous tasks allows the browser to execute other operations and prevents it from freezing.

Callback Queue

This is a queue data structure. Whenever setTimeout or setInterval needs to be called after a particular duration, the Web APIs cannot directly push the code to the call stack as it would pause the current execution of the call stack, potentially leading to unexpected results. To avoid this, there is a buffer-like zone, so all the callbacks to be executed go from Web APIs to the callback queue before reaching the call stack.

Microtask Queue

Similar to the callback queue but used for promises (It is given greater priority than the callback queue).

Javascript Runtime Environment

How the event loop works

Synchronous Code

First, let's start by seeing how the event loop works for normal synchronous code. Consider the following code:

function A() {
  console.log("A");
}
function B() {
  console.log("B");
}
function C() {
  console.log("C");
}

A();
B();
C();
Enter fullscreen mode Exit fullscreen mode

As the execution pointer starts from the first line, function A gets pushed to the stack, is executed, and then popped off the stack. The same thing happens with B and then C. All this happens sequentially. Here nothing other that call stack and memory heap are included.

Asynchronous Code

But when there is asynchronous code included, the JavaScript engine cannot handle these by itself. This is where the Web APIs, event loop, task queue, and microtask queue come into play. Let's visualize the execution flow of code that includes setTimeout:

function A() {
  console.log("A");
}
setTimeout(function B() {
  console.log("B");
}, 1000);
function C() {
  console.log("C");
}

A();
C();
Enter fullscreen mode Exit fullscreen mode

Here, as usual, the execution pointer starts from the first line, pushes A onto the stack, executes it, and pops it off. After this, setTimeout is pushed to the call stack. The callback function along with the timer is passed to the Web APIs to handle, and setTimeout is popped off the stack. The function C is then pushed to the stack, executed, and popped off. When the time defined in setTimeout elapses, the callback function is passed to the task queue. The event loop keeps checking if there is anything in the task queue and call stack. If there is anything in the task queue and the call stack is empty, the function in the queue is passed to the call stack, where it is executed as normal synchronous code.

Promises

Let's go through the code that includes a promise:

function A() {
  console.log("A");
}
const promise = new Promise((resolve) => {
  setTimeout(() => resolve("B"), 1000);
});
promise.then((res) => {
  console.log(res);
});
function C() {
  console.log("C");
}

A();
C();
Enter fullscreen mode Exit fullscreen mode

Here, as usual, function A is pushed to the stack, gets executed, and pops off. Then the promise object is created and passed to the memory heap, and the async code is passed to Web APIs to be executed. Concurrently, the execution pointer moves to the next line, and when it scans the .then(), it assigns the callback passed to the then to the resolve value of the promise. Then it pushes function C to the call stack, executes it, and pops it off. Once the async code is done executing, the callback along with the returned value is passed to the microtask queue. The event loop keeps polling the call stack, and when the call stack is empty, it moves the callback along with the value to the call stack, where it gets executed.

Resources:

Asynchronous JavaScript Crash Course: https://youtu.be/exBgWAIeIeg?si=ccrAcUXnQS0gJgWE
MDN Docs: https://developer.mozilla.org/en-US/docs/Learn/JavaScript/Asynchronous

Conclusion

Thank you for taking the time to read this blog post till the finish. I have plans to start a series where I share about what I did as a developer or anything I learned throughout the week. I am planning to write weekly, so if you find this blog interesting, make sure to keep an eye out for future posts.
Plus, if you have any feedback, or corrections, please let me know. Your input is valuable and helps improve the content for everyone.
Have a great day✨!

Top comments (0)