In this section, we will be discussing the concept of asynchronous behaviour in JavaScript. Asynchronous operation refers to the process where the computer is allowed to perform other tasks while waiting for a particular operation to complete. Asynchronous programming enables us to execute time-consuming operations without interrupting other ongoing processes in our programs.
Asynchronous operations are ubiquitous in our day-to-day lives. For instance, doing household chores involves various asynchronous operations such as a dishwasher washing dishes or a washing machine cleaning clothes. While these operations are in progress, we are free to carry out other tasks.
Likewise, in web development, we use asynchronous operations such as making a network request or querying a database that can take time to complete. JavaScript allows us to perform other tasks while we wait for these operations to complete.
Now, let’s understand it better by learning about concurrency in javascript:
Concurrency in Javascript:
As you may already know, JavaScript is executed on a single thread that is event-based and responds to events when they occur. So, how does it manage to not block other functions from executing?
The answer is simple - it does block them. When it comes to functions, they are hoisted in JavaScript. If functions declared in variables are called before being defined, they will not execute as the variable doesn't exist. On the other hand, normal functions are hoisted and executed anyway.
sayHi()
function sayHi() {
console.log("Hello")
}//this will worksayHello()
let sayHello = () => {
console.log("hello")
}//this will not work
JavaScript code is parsed sequentially, which is not desirable when we want to achieve concurrency. However, JavaScript provides three features that allow us to run code concurrently: Callbacks, Promises, and Async/Await.
Therefore, let's begin to learn about these three features that enable concurrent code execution, starting with Callbacks.
Callbacks in Javascript:
JavaScript treats functions as first-class citizens, which means that they can be passed as arguments to other functions.
A callback is a function that is passed as an argument to another function for execution at a later time.
To understand this concept better, let's take an example of the filter()
method in JavaScript.
The filter()
method accepts an array of numbers and returns a new array of odd numbers. Here's an example:
function filter(numbers) {
let results = [];
for (const number of numbers) {
if (number % 2 != 0) {
results.push(number);
}
}
return results;
}
let numbers = [1, 2, 4, 7, 3, 5, 6];
console.log(filter(numbers));
How it works.
To make the filter()
function more flexible and reusable, you can extract the logic in the if
block and put it into a separate function. Then, you can pass this function as an argument to the filter()
function.
This allows you to filter arrays based on different criteria, without having to modify the filter()
function every time.
For example, if you want to return an array that contains even numbers, you can define a new function isEven()
that checks if a number is even or not.
You can then pass this isEven()
function as an argument to the filter()
function to get the even numbers.
Here’s the updated code:
function isOdd(number) {
return number % 2 != 0;
}
function filter(numbers, fn) {
let results = [];
for (const number of numbers) {
if (fn(number)) {
results.push(number);
}
}
return results;
}
let numbers = [1, 2, 4, 7, 3, 5, 6];
console.log(filter(numbers, isOdd));
The above example produces the same result as the previous one. However, it demonstrates that you can pass any function that accepts an argument and returns a boolean value as the second argument of the filter()
function.
The isOdd
function is an example of a callback function or simply a "callback" because it is passed as an argument to the filter()
function. When a function accepts another function as an argument, it is known as a "higher-order function."
Callbacks can be categorized into two types: synchronous and asynchronous callbacks.
Synchronous Callbacks:
When a callback is executed during the execution of the high-order function, it is called a synchronous callback. In this case, the callback function is executed synchronously during the execution of the filter()
function. In the example provided, isOdd
is an example of a synchronous callback.
Asynchronous Callbacks:
Consider an example scenario where you are required to develop a script for downloading a picture from a remote server and processing it after the download is complete.
In this scenario, since downloading the picture from the remote server is an asynchronous operation, JavaScript will not wait for it to complete and will continue executing the remaining code. Therefore, we can use a callback function that will be executed after the download is complete.
To implement this, we can use the built-in fetch()
function, which returns a promise that resolves to the response of the server. We can then extract the image data from the response and pass it to a callback function for further processing. Here's an example:
function download(url) {
// ...
}
function process(picture) {
// ...
}
download(url);
process(picture);
The process of downloading an image from a remote server can take time based on the network speed and the size of the image.
To simulate the network request, the download()
function shown below uses the setTimeout()
function:
function download(url) {
setTimeout(() => {
// script to download the picture hereconsole.log(`Downloading ${url} ...`);
},1000);
}
Here is an example of the process()
a function that logs a message to the console after processing the downloaded image:
function process(picture) {
console.log(`Processing ${picture}`);
}
If you execute the code below:
let url = '<https://www.almabetter.net/pic.jpg>';
download(url);
process(url);
The output will be:
Processing <https://almabetter.net/pic.jpg>
Downloading <https://almabetter.net/pic.jpg> ...
To fix this issue, you can pass the process()
function as a callback to the download()
function. The download()
function will execute the process()
function once the download completes.
Here's how you can modify the code:
Pass the
process()
function as a callback to thedownload()
function.Modify the
download()
function to execute the callback once the download completes.
function download(url, callback) {
setTimeout(() => {
// script to download the picture hereconsole.log(`Downloading ${url} ...`);
// process the picture once it is completed
callback(url);
}, 1000);
}
function process(picture) {
console.log(`Processing ${picture}`);
}
let url = '<https://wwww.almabetter.net/pic.jpg>';
download(url, process);
Output:
Downloading <https://www.almabetter.net/pic.jpg> ...
Processing <https://www.almabetter.net/pic.jpg>
In the previous example, the process()
function was passed as a callback to an asynchronous function. This means that the process()
function will only execute after the completion of the asynchronous operation.
Hence, a callback that is used to continue the execution of code after an asynchronous operation is known as an asynchronous callback.
Callback Hell or Pyramid of Doom:
The pattern of nesting multiple asynchronous functions inside callbacks is commonly referred to as the "pyramid of doom" or "callback hell".
However, this approach can become unwieldy and difficult to manage as the complexity of the codebase grows.
Consider the example shown below:
asyncFunction(function(){
asyncFunction(function(){
asyncFunction(function(){
asyncFunction(function(){
asyncFunction(function(){
....
});
});
});
});
});
To avoid the issue of the pyramid of doom, JavaScript provides Promises and async/await functions as alternatives.
Now, let's focus on Promises and understand how they work in JavaScript.
Promises in Javascript:
A Promise is an object that represents the outcome of an asynchronous operation that will be completed in the future. A Promise object can exist in one of three possible states:
Pending: This is the initial state when the operation has not yet completed.
Fulfilled: This state indicates that the operation has completed successfully and the Promise object has a resolved value, which could be anything like a string, number, object, or any other value.
Rejected: This state indicates that the operation has failed, and the Promise object has a reason for the failure, typically an Error object.
The state of a promise can be one of three states: pending, fulfilled, or rejected. Once a promise is no longer pending and has either been fulfilled or rejected, we say that it has been settled. To illustrate this, we can consider a dishwasher as an example. The dishwasher's states can be thought of as follows:
Pending: The dishwasher is currently running and has not yet completed the washing cycle.
Fulfilled: The dishwasher has completed the washing cycle and now contains clean dishes.
Rejected: The dishwasher encountered a problem, such as not receiving soap, and the dishes remain dirty.
Once our dishwasher promise is fulfilled, we can perform subsequent tasks such as unloading the clean dishes from the dishwasher. However, if the promise is rejected, we can take alternative steps such as running it again with soap or resorting to washing the dishes by hand.
Creating a Promise:
To create a new Promise
object, we use the new
keyword followed by the Promise
constructor method:
const executorFunction = (resolve, reject) => { };
const myFirstPromise = new Promise(executorFunction);
When constructing a new Promise
object, the Promise
constructor method is used with the new
keyword. The constructor method takes an executor function as its parameter, which runs automatically when the constructor is called.
The executor function usually initiates an asynchronous operation and determines how the promise should be settled.
The executor function has two function parameters, called resolve()
and reject()
. These functions are not defined by the programmer.
When the Promise
constructor runs, JavaScript passes its own resolve()
and reject()
functions into the executor function.
The
resolve()
function has one argument, and when invoked, it changes the promise's status frompending
tofulfilled
. The resolved value of the promise is set to the argument passed intoresolve()
.The
reject()
function takes a reason or error as its argument. When invoked, it changes the promise's status frompending
torejected
. The rejection reason of the promise is set to the argument passed intoreject()
.
Consider the example shown below:
const executorFunction = (resolve, reject) => {
if (someCondition) {
resolve('I resolved!');
} else {
reject('I rejected!');
}
}
const myFirstPromise = new Promise(executorFunction);
Let's explain the code snippet above:
We define a variable called
myFirstPromise
.We create a new promise object using the
Promise
constructor method by passing anexecutorFunction
to it as an argument.The
executorFunction()
takes two functions as parameters,resolve
andreject
.If the condition
someCondition
is true, theresolve()
function is called with the argument'I resolved!'
, which sets the promise's status tofulfilled
and resolves it with the value'I resolved!'
.If the condition
someCondition
is false, thereject()
function is called with the argument'I rejected!'
, which sets the promise's status torejected
and rejects it with the reason'I rejected!'
.
Consuming Promises:
The .then()
method is used to specify what should happen when a promise settles, which can either be fulfilled or rejected. It allows us to define a chain of actions that will take place in response to a settled promise.
For instance, in the case of our dishwasher promise, we can use the .then()
method to specify that:
If the promise is rejected, indicating that the dishes are dirty, we'll add soap and run the dishwasher again.
If the promise is fulfilled, indicating that the dishes are clean, we'll put the dishes away.
The .then()
method is a higher-order function that takes two callback functions as arguments, known as handlers. These handlers are invoked with the settled value of the promise when it is fulfilled or rejected.
The first handler, often referred to as onFulfilled
, contains the logic for resolving the promise, while the second handler, known as onRejected
, contains the logic for handling promise rejection.
Note: The usage of .then()
method offers flexibility as we can pass one, both, or none of the handlers. However, it can make debugging complex. If a required handler is not provided, instead of throwing an error, .then()
returns a promise with the same settled value as the original promise. It's worth noting that .then()
always returns a promise.
We use the .then()
method on a promise to handle its successful resolution by passing in a callback function called a success handler.
Consider the example shown below:
const prom = new Promise((resolve, reject) => {
resolve('Yay!');
});
const handleSuccess = (resolvedValue) => {
console.log(resolvedValue);
};
prom.then(handleSuccess); // Prints: 'Yay!'
Let’s break down what’s happening in the example code:
In the given code example, a new promise
prom
is created which is set to resolve to the string value'Yay!'
.Then, a function
handleSuccess()
is defined which logs the argument passed to it.Finally, the
then()
function is called on theprom
promise, passing thehandleSuccess()
function as an argument.Since the
prom
promise resolves successfully, thehandleSuccess()
function is invoked with the resolved value'Yay!'
, and this value is logged to the console.
Catch() method with Promises:
It's important to note that if no appropriate handler was provided to the .then() method, it will return a promise with the same settled value as the promise it was called on.
This enables us to keep our resolved logic and rejected logic separate by chaining a second .then() method with a failure handler to a first .then() method with a success handler. This way, both resolved and rejected cases can be handled separately.
prom
.then((resolvedValue) => {
console.log(resolvedValue);
})
.then(null, (rejectionReason) => {
console.log(rejectionReason);
});
To improve the readability of promise chains, we can put each part of the chain on a new line. Additionally, we can use a different promise function called .catch()
which takes only one argument, the failure handler called onRejected
.
When a promise is rejected, the failure handler is invoked with the reason for rejection. Using .catch()
is equivalent to using a .then()
function with only a failure handler.
Consider the following example that uses .catch()
:
prom
.then((resolvedValue) => {
console.log(resolvedValue);
})
.catch((rejectionReason) => {
console.log(rejectionReason);
});
Let’s break what’s happening in the example code:
In the example code, there is a promise called
prom
that may either resolve with the string'Yay!'
or reject with the string'Ohhh noooo!'
.To handle the settled value of the promise, the
.then()
method is called with a success handler that logs the resolved value to the console.Additionally, a **
.catch()
**method is chained to the promise, passing a failure handler that logs the rejection reason to the console.If the promise resolves successfully, the success handler in
.then()
is called, but if it rejects, the failure handler in.catch()
is called with the rejection reason.
Chaining multiple Promises:
Let me provide an example that illustrates how we can chain promises together using the concept of composition.
Suppose we have some dirty clothes that we want to wash. We put the dirty clothes in the washing machine and start it. If the clothes are cleaned, we want to put them in the dryer. Once the dryer runs, if the clothes are dry, we can fold them and put them away.
We can represent this process using a promise chain in code.
firstPromiseFunction()
.then((firstResolveVal) => {
return secondPromiseFunction(firstResolveVal);
})
.then((secondResolveVal) => {
console.log(secondResolveVal);
});
Let me rephrase the breakdown of the example code:
The code starts by calling a function named
firstPromiseFunction()
, which returns a promise.We then chain a
.then()
function to the first promise, passing an anonymous function as a success handler.Inside the success handler, we create and return a new promise. This promise is the result of calling another function named
secondPromiseFunction()
with the resolved value of the first promise as an argument.We chain another
.then()
to the second promise to handle the case when the second promise settles.Inside this
.then()
, we define a success handler that logs the resolved value of the second promise to the console.
Note: To make sure that our promise chain works correctly, we needed to explicitly return the promise generated by secondPromiseFunction(firstResolveVal)
inside the success handler of the first .then()
. This was necessary to ensure that the value passed to the next **.then()
**was the resolved value of the second promise rather than a new promise with the same settled value as the first one.
Promise.all() method:
Consider a cleaning scenario where we want our house to be clean, which means we need multiple tasks to be completed asynchronously such as drying clothes, emptying trash bins, and running the dishwasher.
Although these tasks can be completed in any order, all of them must be completed to consider the house clean. To achieve concurrency, we can use the function Promise.all()
in JavaScript promises.
The function Promise.all()
takes an array of promises as input and returns a promise. This returned promise can settle in two ways:
If all the promises in the input array are resolved, the returned promise from
Promise.all()
will also be resolved with an array containing the resolved values from each promise in the input array.If any promise from the input array is rejected, the returned promise from
Promise.all()
will immediately be rejected for the reason that promise was rejected. This behavior is commonly known as "failing fast".
Consider the example given below:
let myPromises = Promise.all([returnsPromOne(), returnsPromTwo(), returnsPromThree()]);
myPromises
.then((arrayOfValues) => {
console.log(arrayOfValues);
})
.catch((rejectionReason) => {
console.log(rejectionReason);
});
Here's what's happening in the code:
We define a variable called
myPromises
and assign it the result of callingPromise.all()
.Promise.all()
takes an array of three promises, which are the return values of three functions.If all promises resolve successfully, the success handler passed to
.then()
will be called and will print the array of resolved values.If any promise rejects, the failure handler passed to
.catch()
will be called and will print the first rejection message.
Async/Await Keywords in Javascript:
The Async keyword:
The keyword **async**
is used to define functions that handle asynchronous actions. By adding the **async**
keyword before the function declaration, we can write asynchronous logic inside the function. Later, we can invoke that function to perform the asynchronous operation.
async function myFunc() {
// Function body here
};
myFunc();
Throughout this lesson, we have been using async function declarations. However, it is also possible to create async function expressions.
const myFunc = async () => {
// Function body here
};
myFunc();
When an async
function is invoked, it will always return a promise. This allows us to use traditional promise syntax such as .then()
and .catch()
with async
functions. There are three ways in which an async
function can return:
If the function doesn't return anything, it will resolve with a value of
undefined
.If the function returns a non-promise value, the returned value will be wrapped in a resolved promise.
If the function returns a promise, the promise returned by the function will be returned as is.
Consider the example shown below:
async function fivePromise() {
return 5;
}
fivePromise()
.then(resolvedValue => {
console.log(resolvedValue);
}) // Prints 5
In the given example, although the function body returns the value 5, when the function fivePromise()
is invoked, it actually returns a promise with the resolved value of 5.
The Await Operator:
The keyword await
is an operator that can only be used inside an async
function. It pauses the execution of the function until a given promise is resolved and returns the resolved value of the promise. Since promises resolve at an unknown time, using await
allows us to write asynchronous code in a synchronous style.
Typically, we use await
to wait for the resolution of a promise returned by another function inside an async
function.
Consider the example shown below:
async function asyncFuncExample(){
let resolvedValue = await myPromise();
console.log(resolvedValue);
}
asyncFuncExample(); // Prints: I am resolved now!
The given code snippet showcases the use of the await
keyword inside an async
function. The function myPromise()
returns a promise which resolves to the string "I am resolved now!".
Inside the async
function asyncFuncExample()
, we use await
to pause the execution until the promise returned by myPromise()
is resolved.
Then we assign the resolved value to the variable resolvedValue
and log it to the console. This allows us to write asynchronous code in a way that looks like synchronous code.
Handling Dependent Promises:
Using async...await
becomes particularly powerful when we need to perform a sequence of asynchronous actions that rely on one another. For instance, we may need to wait for the result of a database query before making a network request. In such cases, we can use await
to pause the execution until a promise is resolved, enabling us to write code that looks more like synchronous code and avoids deeply nested chains of .then()
functions.
For this consider the following example given below:
function nativePromiseVersion() {
returnsFirstPromise()
.then((firstValue) => {
console.log(firstValue);
return returnsSecondPromise(firstValue);
})
.then((secondValue) => {
console.log(secondValue);
});
}
Let’s analyze what is happening in the function nativePromiseVersion()
:
The function declares two functions that return promises:
returnsFirstPromise()
andreturnsSecondPromise()
.returnsFirstPromise()
is invoked, and the first promise is resolved using.then()
.In the first
.then()
callback, the resolved value of the first promise,firstValue
, is logged to the console, andreturnsSecondPromise(firstValue)
is returned.Another
.then()
is used to print the resolved value of the second promise to the console.
Let's now take a look at how we can achieve the same using the async...await
syntax:
async function asyncAwaitVersion() {
let firstValue = await returnsFirstPromise();
console.log(firstValue);
let secondValue = await returnsSecondPromise(firstValue);
console.log(secondValue);
}
In the asyncAwaitVersion()
function, we use the async
keyword to indicate that it contains asynchronous code.
We create a variable called
firstValue
and assign it the resolved value of thereturnsFirstPromise()
function using theawait
keyword.Then, we log the value of
firstValue
to the console.After that, we create another variable called
secondValue
and assign it the resolved value of thereturnsSecondPromise(firstValue)
function using theawait
keyword.Finally, we log the value of
secondValue
to the console.
The primary benefit of using the **async...await**
syntax is not just about reducing the length of code, but rather it allows the code to closely resemble synchronous code, making it easier for developers to maintain and debug. Additionally, async...await simplifies the process of referencing resolved values from promises earlier in the code, which is much more challenging with native promise syntax.
Handling Errors:
If a long promise chain is used with .catch()
, it can be difficult to determine where the error occurred, making debugging a challenge. However, with the async...await
syntax, we can use try...catch
statements to handle errors.
This approach allows us to handle errors in a way similar to synchronous code, and it can catch both synchronous and asynchronous errors. As a result, debugging becomes more straightforward.
Consider the following example shown below:
async function usingTryCatch() {
try {
let resolveValue = await asyncFunction('thing that will fail');
let secondValue = await secondAsyncFunction(resolveValue);
} catch (err) {
// Catches any errors in the try block
console.log(err);
}
}
usingTryCatch();
The given code sample demonstrates the usage of a try...catch
block within an asynchronous function. Within the try
block, the code awaits the result of a request. If an error occurs during this process, the program flow transitions to the catch
block where the error is properly handled.
Await Promise.all() method:
To optimize the execution of multiple promises that can run simultaneously, we can use Promise.all()
in conjunction with the await
keyword.
By passing an array of promises as an argument to Promise.all()
, a single promise is returned that resolves, when all promises within the argument array, have resolved. The resolved values of each promise from the argument array are contained within an array that is returned by the promise's resolve value.
Consider the following example below:
async function asyncPromAll() {
const resultArray = await Promise.all([asyncTask1(), asyncTask2(), asyncTask3(), asyncTask4()]);
for (let i = 0; i<resultArray.length; i++){
console.log(resultArray[i]);
}
}
In the previous code example, we utilized await
to wait for the completion of a Promise.all()
method that was called with an array of four promises, returned by functions that were imported via the require
keyword. Following that, we looped through the resultArray
and printed each item to the console.
The resolved value of the asyncTask1()
promise is stored in the first element of resultArray
, the second element contains the resolved value of the asyncTask2()
promise, and so on for the rest of the promises in the array.
Note The usage of Promise.all()
enables the benefits of asynchronicity, allowing each of the four asynchronous tasks to process concurrently. Additionally, Promise.all()
has the advantage of failing fast, which means that it will not wait for the remaining asynchronous actions to complete once anyone has been rejected. If the first promise in the array is rejected, then the promise returned from Promise.all()
will reject for that particular reason. Similar to working with native promises, Promise.all()
is an optimal choice if multiple asynchronous tasks are required to execute, but none of them are dependent on one another to complete.