Concurrency In JavaScript – Understanding Async/Await

Concurrency is the ability of a program to execute multiple tasks simultaneously. In a programming language, concurrency refers to the ability of a program to run multiple processes at the same time. This can be achieved by running multiple threads or by using multiple processors.

Concurrency is specifically important when dealing with I/O requests, such as network requests, reading/writing from disk, or waiting for user input on console. Basically, anything where the CPU has to wait for an external event to complete.

There are two ways programs typically wait for I/O. The first one is blocking (synchronous)  and the second one is non-blocking (asynchronous).

Blocking: The program halts its execution until the external event has finished. It is –

  • Easy to write
  • Not susceptible to race conditions
  • Synchronous errors are easier to debug

Asynchronous: The program does not halt its execution and instead registers a callback function to be executed when the external event has finished. It is –

  • Harder to write
  • Susceptible to race conditions
  • Asynchronous errors are harder to debug

All modern JavaScript engines including Node.js and Chrome browser, use the asynchronous approach.

Concurrency is advantageous because it allows a program to make better use of resources, such as CPU time and memory. However, concurrency can also introduce errors into a program, so it must be used with care.

Concurrency In JavaScript

Concurrency in JavaScript is quite different than concurrency in most other languages. The main reason is that JavaScript is single-threaded, meaning that only one task can be executed at a time. In other language, such as Java or Go, multiple threads can be running simultaneously.

In the world of programming languages, there is always a trade-off between flexibility and power on one hand, and simplicity and performance on the other. Rust offers flexibility and power, but at the expense of simplicity. Go offers simplicity and performance, but at the expense of flexibility.

And JavaScript? JavaScript was never intended to deal with concurrency in the first place. But the fantastic minds behind NodeJS were able to create a platform that was entirely devoted to concurrency and non-blocking I/O despite this. As a result, despite its lack of concurrency support in the language itself, JavaScript has become incredibly popular for applications that need to deal with a lot of concurrent requests, such as web servers.

JavaScript uses an event loop to allow concurrent execution of tasks.

Let’s have a look at what an event loop is and what it does in JavaScript.

Event Loop

event loop in javascript
Source: https://miro.medium.com/max/1400/1*iHhUyO4DliDwa6x_cO5E3A.gif

The event loop is a programming model that enables asynchronous, message-passing concurrency in JavaScript. Under this model, a program can register event handlers for certain events, and then wait for those events to occur. When an event does occur, the program can execute the appropriate event handler.

This type of programming can be very useful for responding to user input, handling network requests, and other tasks that require a program to wait for something to happen. Because the event loop allows a program to register multiple event handlers, it can be used to create complex applications that can respond to many different types of events.

In JavaScript, the event loop is implemented by the event model, which uses a queue to store events that are waiting to be processed.

The event loop begins when an event is triggered, such as when a user clicks a button. The event is added to the queue, and the event loop proceeds to process the next event in the queue. Once all events in the queue have been processed, the event loop waits for new events to be triggered. This process repeats indefinitely, making the event loop an essential part of any JavaScript program.

For example, if a JavaScript program is trying to fetch data from a remote server, the program can continue running other code while it waits for the data to arrive.

Callbacks – The Traditional Method Of Concurrency In JavaScript

In programming, a callback is a function that is passed as an argument to another function. When the other function is invoked, it calls the callback function.

Callbacks are commonly used to handle asynchronous operations such as file I/O or network requests.

Callbacks are an important part of JavaScript programming, and they can be used to write more efficient and responsive applications.

Whenever an event occurs in a computer program, the main thread of execution is typically responsible for handling the event. This often involves delegating the task to a separate handler function, which can then process the event while the main thread returns to listening for more events.

This approach is efficient because it allows the program to immediately respond to events as they occur, without having to wait for a task to finish processing before moving on. 

For example, consider a program that reads a large file from disk. If the file was read synchronously, the program would be blocked until the entire file had been read. However, if the file was read asynchronously, the program could continue executing while the file was being read. When the file reading operation was complete, a callback function would be invoked to process the data that was read.

readfile("file.txt", (content) => {

console.log(content);

});

Pros

  • Great low level abstraction
  • Performant when the overhead is low
  • Any async task can be performed with callbacks

Cons

  • Doing things in sequence is hard, doing things in parallel is harder
  • No for/while and try/catch constructs
  • You can’t throw inside a callback, so you have to pass errors as additional arguments
  • Callbacks often lead to nested code (callback hell) that is difficult to read and manage

Here is an example of a program that will just not work – 

function getUserName() {

let name;

$.get("/users/123", (user) => {

name = user.name;

});

return name;

}

console.log("User Name:", getUserName());

It is because the name is declared as empty, and it gets returned empty from the function before the inner function is executed.

Non Blocking I/O In Node.js

The non-blocking I/O model means that Node.js can handle a large number of concurrent connections with high throughput. This makes it perfect for building scalable network applications. If you’re looking for a JavaScript runtime environment that is lightweight and efficient, then Node.js is the right choice for you.

Practical Problems With Callbacks

Despite the many benefits of using callbacks, there are some practical problems that can arise.

Callback Hell

Callback hell is a term used to describe a situation where a program has too many nested callbacks. This can make the code difficult to read and understand. 

Here’s an example of callback hell – 

function getTotalFileSize(file1, file2, file3, callback) {

let total = 0;

stat(file1, (error, info) => {

total+=info.sie;

stat(file2, (error, info) => {

total+=info.size;

stat(file3, (error, info) => {

total+=info.size;

callback(total);

});

});

});

}

See how the complexity grows, and we are not even doing any error handling here!

Error Handling In Callbacks

Another practical problem with callbacks is that it can be difficult to handle errors.

If an error occurs in a callback function, it can be difficult to propagate the error up to the caller. This is because the callback function is invoked asynchronously, so the caller may have already returned by the time the error occurs.

This can make it difficult to write robust callback-based code.

Let’s see how the above code looks like if we add  a little bit of error-handling to it –

stat(file1, (error, info) => {

if(error) {

console.error(error);

return;

}

total+= info.size;

stat(file2, (error, info) => {

console.error(error);

return;

}

total+=info.size;

stat(file3, (error, info) => {

if(error) {

console.error(error);

return;

}

total+=info.size;

});

});

});

This is a huge block of code, and it’s not even good error handling!

Solutions To Callbacks – Modern JavaScript Concurrency

There are a few different solutions to the problems that can arise with callbacks.

Promises

In JavaScript, a promise is an object that represents the result of an asynchronous operation. It is a thin but powerful abstraction on top of callbacks that provides –

  • easy chaining
  • error handling
  • better readability
  • You can think of a promise as a placeholder for a value that will be returned at some point in the future.

A promise can be in one of three states: pending, fulfilled, or rejected.

When a promise is first created, it is in the pending state. This means that the async operation has not yet completed.

Once the async operation has finished, the promise will be either fulfilled or rejected.

If the async operation was successful, the promise will be fulfilled and the value of the async operation will be passed to any success handlers. If the async operation was not successful, the promise will be rejected and the reason for the failure will be passed to any error handlers.

Promises provide a way to handle asynchronous operations in a more elegant way than traditional callback functions.

A promise object can be created using the Promise constructor:

const myPromise = new Promise((resolve, reject) => {

    // do something async here

});

The promise constructor takes a function as an argument. This function is called the executor function. The executor function takes two arguments: resolve and reject.

The resolve function is used to resolve the promise. The reject function is used to reject the promise.

Once a promise has been resolved or rejected, it can not be changed.

const myPromise = new Promise((resolve, reject) => {

    // do something async here

    if (/* everything went well */) {

         resolve("Success!"); 

    } else {

         reject("Failure!");

    }

});

Promises can be chained together using the then() method. The then() method takes two arguments: a success handler and an error handler.

The success handler is invoked if the promise is fulfilled. The error handler is invoked if the promise is rejected.

myPromise.then((value) => {

    // success handler

    console.log(value);

}.catch( (reason) => {

    // error handler

    console.log(reason);

});

The then() method returns a new promise. This allows promises to be chained together.

return promise1.then(output1=> {

return promise2.then(output2 => {

 return promise3.then(output3 => {

  console.log(output3)

 })

})

})

If the success handler returns a promise, the next promise in the chain will be resolved with the value of that promise.

If the success handler throws an error, the next promise in the chain will be rejected with the reason for the error.

The catch() method can be used to handle errors in promises. The catch() method takes a single argument: an error handler function.

This function is invoked if the promise is rejected.

Although this is much better than callbacks, since we can chain and catch errors, it still looks a bit messy.

We can make our code look cleaner by using the async/await keywords.

Async/Await

async/await is a new keyword in JavaScript that allows you to write asynchronous code more easily.

async functions always return a promise, so you can use await to wait for the async function to finish before doing something else.

For example, if you have an async function that fetches data from a server, you can use await to wait for the data to arrive before continuing. await only works inside of an async function, so you’ll need to use async/await together. This syntax can make asynchronous code much easier to read and write.

The async/await keywords were introduced in JavaScript in ES2017. Async functions are essentially a way to write promise-based code without using “then” or “catch” blocks.

Async/await is not just a syntax sugar, it also optimizes performance by avoiding Promises nesting. When chaining Promises, every then() block creates a new Promise, which has to be resolved before the next then() can be executed. With async/await you can avoid this by awaiting for the inner Promise to be resolve before moving on to the next line of code.

Here is an example of an async function:

async function fetchData() {

    const data = await fetch("https://api.mydomain.com/data");

    console.log(data);

}

This function uses the await keyword to wait for the fetch() function to finish before logging the data to the console.

This looks much similar to the code we write in Java or other similar languages, if you come from that background.

The await keyword can only be used inside of an async function. If you try to use it outside of an async function, you’ll get a SyntaxError.

If you’re not familiar with the fetch() function, it’s a built-in JavaScript function that allows you to make network requests.

It’s equivalent to using XMLHttpRequest() or axios, but it’s much easier to use.

To use async/await, you’ll need a JavaScript runtime that supports ES2017 ( most modern browsers support async/await).

If you’re using Node.js, you’ll need to use a version that supports async/await (version 7.6 or higher).

If you’re using an older version of Node, you can use the require(‘es2017-promise’) module to polyfill promises.

Async functions always return a promise, so you can use .then() and .catch() blocks with async functions just like you would with any other promise.

If the async function returns a value, the promise will be resolved with that value.

If the async function throws an error, the promise will be rejected with that error.

Here’s an example of an async function that returns a value: 

async function fetchData() {

    const data = await fetch("https://api.mydomain.com/data");

    return data;

}

fetchData().then(response => console.log(response)); //prints the data to the console

In this example, the fetchData() function returns the data that is fetched from the server.

You can then use the .then() block to do something with the data. In this case, we’re just logging it to the console.

If you want to catch errors in async functions, you can use .catch() blocks just like you would with any other promise.

Here’s an example of an async function that throws an error: 

async function fetchData() {

    const data = await fetch("https://api.mydomain.com/data");

    throw new Error("something went wrong");

    return data;

}

fetchData().catch(error => console.log(error)); //prints an error to the console

In this example, the async function throws an error when it tries to fetch the data from the server.

You can use .catch() to catch the error and do something with it. In this case, we’re just logging it to the console.

Async/await makes asynchronous code look and feel like synchronous code. This is because await pauses the execution of the async function until the Promise resolves.

If you have multiple promises that you want to wait for, you can use Promise.all().

Promise.all() takes an array of Promises as an input and waits for all of the Promises to resolve before moving on.

Here’s an example: 

async function fetchData() {

    const data = await Promise.all([

         fetch("https://api.mydomain.com/data1"),

         fetch("https://api.mydomain.com/data2")

    ]);

    console.log(data);

}

In this example, the fetchData() function is waiting for two Promises to resolve.

Once both Promises have resolved, the data is logged to the console.

Concurrency In JavaScript – Conclusion

There are two main types of code used in programming: concurrent and synchronous.

Concurrent code is code that can be executed independently of other code. This means that multiple pieces of concurrent code can be executed at the same time, without affecting each other.

Synchronous code, on the other hand, is code that must be executed in a specific sequence. This can often lead to bottlenecks, as one piece of code may need to wait for another to finish before it can be executed. For this reason, concurrent code is often seen as being more efficient than synchronous code.

While synchronous code can still be used in some situations, concurrent code is generally seen as the better option. It is more flexible and scalable, and it can make better use of resources.

Async/await is a way of writing concurrent code in JavaScript in a more synchronous-like fashion.

It allows you to pause the execution of an async function until a Promise resolves, and it makes working with Promises easier.

If you’re using Node.js, you’ll need to use a version that supports async/await.

If you’re using a browser, you’ll need to use a polyfill.

Async/await is not supported in all browsers yet, but support is growing.

You can check the compatibility table to see if your browser supports async/await.

Async/await is a relatively new feature, and it’s still being refined.

If you’re using it in production, you should keep an eye on the changes to see if anything new is added that could affect your code.

Resources:

https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/async_function

https://www.npmjs.com/package/es6-promisify

Leave a Reply