How JavaScript works under the Hood
By default, Javascript is single-threaded, meaning the JavaScript engine can only execute one thing at a time. i.e., the code is executed sequentially, line by line.
JavaScript uses the call stack, which is a data structure that keeps track of function calls in your code, and since JavaScript is single-threaded, it also means that there is only one call stack .
Because of the single call stack, only one function can be executed at a time.
Consider the example below:
function fetchData() {
console.log("Fetching data from API...");
console.log("Data fetched successfully:", { id: 1, name: "Example Data" });
displayData({ id: 1, name: "Example Data" });
}
function displayData(data) {
console.log("Displaying data to the user:", data);
}
fetchData();
The first time the program starts running, a global execution context is created. Next, the fecthData()
function is pushed to the call stack. In this call stack, the 2 console.log()
statements are executed.
When the displayData()
is called, it is pushed to the call stack, and the console.log()
statement "Displaying data to the user" is executed. fetchData()
is resumed and completes the execution.
Now fetchData()
has completed execution and is pushed off the stack, and the program execution is finally completed.
If we invoke the function, this is the result.
If the fetchData()
function takes too long to execute, it can lead to blocking the main thread, this is where asynchronous operations come in.
Asynchronous JavaSript
Asynchronous code allows other operations within your program to continue running while the asynchronous operation runs in the background, so in short, it doesn't block, or it is called non-blocking.
Some examples that may require running asynchronously are getting data from the server when performing HTTP requests; rather than having to wait for an HTTP request to complete, which might take some time before you get a response, you can use asynchronous code to run the HTTP request in the background.
To properly understand this, we will simulate a setTimeout function in the fetching data function; the code below will have not wait for the setTimeout()
function to execute before going to the next code statement.
function fetchData() {
const start = new Date().getTime();
console.log(" start fetching data from API...");
setTimeout(() => {
console.log(`running backgorund task`);
displayData();
}, 8000);
console.log("If you can see this, am not blocking the main thread");
}
function displayData() {
console.log("Displaying data to the user:");
}
fetchData();
The output of the above code will be:
As you can see from the console.log statements, the code below the setTimeout function runs before the fetching data from API because setTiemout is an example of an asynchronous function that runs in the background and, therefore, does not cause blocking of the main thread.
Promises for Asynchronous operations
To achieve asynchronous operations, we can also make use of promises.
A promise is a response to an Ajax call. A promise can also be defined as a placeholder for a result that may or may not be delivered.
To create a promise, we use the Promise()
constructor like this: The Promise()
constructor takes in a function as an argument, and the function takes in resolve and rejects parameters.
let promise = new Promise(function((resolve, reject));
The lifestyle of a promise consists of the following states:
- pending
- settled (can either be fulfilled or rejected)
A fulfilled promise is a successful response, while a rejected promise results in an error.
let promise = new Promise((resolve, reject) => {
setTimeout(() => {
resolve("Operation successful!");
}, 1000);
});
console.log(promise);
// a promise that will reject
let promise1 = new Promise((resolve, reject) => {
setTimeout(() => {
reject(new Error("Operation failed!"));
}, 1000);
});
console.log(promise1);
The first promise will be successful, while the second will be rejected. Once a promise has been settled, the state cannot be changed. Here is the output.
Consuming Promises
To get the result from a promise, we need to consume it. Promises are consumed by using a .then()method. Consider the following example, which illustrates the concept using the Hangman game.
const secretWord = "cheese";
function guessWord(letter){
let promise = new Promise((resolve, reject)=>{
if (secretWord.includes(letter)){
resolve(`Letter ${letter} is correct`);
}else{
reject(new Error(`Letter ${letter} is incorrect`))
}
})
return promise
}
Hangman is a word-guessing game that challenges players to guess a secret word one letter at a time. If players guess a correct letter, it's revealed in the word. If not, a part of a hangman is drawn. The game continues until the players guess the word correctly or the hangman is completed.
If we invoke the guessWord()
function, we will get a Promise.
To get the value of the promise, we need to consume it by attaching the .then()
method when invoking the function. Inside the .then method, we pass a callback function that will be executed as soon as the promise is fulfilled like this:
guessWord("c")
.then((response) => {
console.log(response); //output //Letter c is correct
})
Error handling
If a promise has not been resolved or fulfilled, then it means that it has been rejected. To handle rejected promises, we can pass a .catch()
method at the end of the chain like this:
guessWord("b")
.then((response) => {
// console.log(response);
return response
})
.catch((error)=> console.log(error.message))
The Fetch API
The Fetch API is a promise-based API for making network requests in web browsers and Node.js environments. Compared to traditional methods like XMLHttpRequest, it provides a more powerful and flexible way to handle HTTP requests. For example, suppose we need to fetch some dummy data from the placeholder API; our fetch will look like this:
fetch("https://jsonplaceholder.typicode.com/todos/1")
.then(response => {
const promise = response.json();
console.log("Promise returned by response.json():", promise);
return promise;
})
.then(data => {
console.log("Fetched data:", data);
})
.catch(error => {
console.error('Fetch error:', error);
});
The first .then()
method returns a promise that resolves to the Response object representing the HTTP response. So when we log the response.json()
object, it returns a Pending Promise.
The second .then()
method waits for the promise returned by the first .then()
method to resolve. and then returns the fetched data
Once the response is successful, the state of the promise will change to fulfilled, and if an error occurs, the state of the promise will be rejected.
If any error occurs during the fetch or the parsing of the response body, it will be caught in the .catch()method.
Async, await
Async, await is an even easier way to consume promises. Async await was introduced in ES2017 to simplify the consumption of promises. As the async function is created by adding the async keyword in any function you want to make asynchronous like this:
async function getDate(){
// perform asynchronous operations here.
}
The await keyword, on the other hand, is used to await responses from the promise, For example, suppose you are making a fetch request to a URL like this:
const response = fetch(URL)
The response object is a promise, and so to get the actual data, we have to consume it with await like this:
const data = await response.json()
await will pause the execution of the async function until the fetch promise is settled. Then, it will retrieve the response data as JSON using the .json()
method.
As you can see above, we have abstracted away all the logic behind the scenes, and we don't have to deal with the headaches of consuming promises by chaining several handler methods.
Async, Await Example
Let's rewrite the example we used above as an async function
async function fetchData() {
try {
const response = await fetch("https://jsonplaceholder.typicode.com/todos/1");
return response.json();
} catch (error) {
console.error('Fetch error:', error);
}
}
fetchData();
Using async await not only simplifies asynchronous code but also makes it easy to handle errors by using a try ..catch block that will catch any error that may occur during the async operation. Whether an error occurs while waiting for the fetch call to resolve or during processing the response data inside the try block, it will be caught and handled in the same catch block
Returning data from async functions
If an async function returns data like above, the returned data is in a pending state, therefore the returned data should also be received in an asynchronous function.
If you invoke the getData()
function like a normal function, you will get a pending promise.
The right way to consume data from an asynchronous function is by using another aync function since await can only be used in an asynchronous function.
async function fetchData() {
try {
const response = await fetch("https://jsonplaceholder.typicode.com/todos/1");
return response.json();
} catch (error) {
console.error('Fetch error:', error);
}
}
async function consumeData() {
try {
const data = await fetchData();
console.log("Consumed data:", data);
} catch (error) {
console.error('Error consuming data:', error);
}
}
consumeData();
//output
// {userId: 1, id: 1, title: 'delectus aut autem', completed: false}
The await keyword can also be used at the top level of a module.
Conclusion
This article has covered everything you need to know about Asynchronous operations in JavaScript. Check out this CodePen, which demonstrates how to use async functions with the OpenAI API to generate quotes.