Mastering Asynchronous JavaScript: A Deep Dive
Asynchronous programming is a fundamental aspect of JavaScript, enabling us to create responsive applications that can handle multiple time-consuming tasks concurrently. Mastering the management of asynchronous code is essential for writing efficient, maintainable, and bug-free JavaScript applications.
In this blog, we’ll delve into the core concepts of asynchronous JavaScript, including callbacks, promises, async/await, and the event loop.
The Need for Asynchronous JavaScript
JavaScript is inherently a single-threaded, blocking, synchronous language. This means it operates with a single call stack and can execute only one line of code at a time in a top-to-bottom sequence. The next line of code won't be executed until the previous line has completed execution.
JavaScript is used to make web applications interactive, often involving interactions with APIs, databases, and other I/O operations. These operations can take time to complete, and if JavaScript waited for each one to finish before moving on to the next, the entire application would freeze, leading to a poor user experience. This is where asynchronous JavaScript comes into play, allowing the browser or Node.js to perform tasks like network requests, file reading, or timers without freezing the main thread.
For example, consider the case of making a http request to fetch data using API. If this operation were synchronous, it would block the entire application, making it unresponsive until the data is retrieved. Asynchronous programming ensures the application remains responsive while waiting for the operation to complete.
Understanding the Event Loop
The Event loop is the core concept in Javascript that drives it’s asynchronous behavior enabling it to perform non-blocking operations despite being single threaded. It continuously checks the call stack and the task queue. If the call stack is empty, the event loop pushes the first task from the task queue to the call stack for execution.
The Call Stack: The call stack is where JavaScript keeps track of function calls. It follows the Last-In-First-Out (LIFO) principle, meaning the last function added to the call stack will be the first to execute. When a function is invoked, it’s pushed onto the stack, and when the function completes, it’s popped off the stack.
Web APIs and Asynchronous Operations: When an asynchronous operation is encountered(setTimeout, a fetch request, or event listeners), the operation is passed to the Web API, which handles it in the background instead of blocking the stack.
The Task Queue/Callback Queue: Once the asynchronous operation is completed, its callback function is passed to the task queue, also known as the callback queue. This queue waits until the call stack is empty before executing the callback.
The Event Loop: The event loop continuously monitors the task queue and the call stack. It checks if the call stack is empty, and if it is, the event loop takes the first callback from the task queue and pushes it to the call stack for execution.
Consider this program, we will see how the JavaScript runtime environment works.
console.log("A");
// function
const welcome = () => {
console.log("B");
console.log("C");
}
setTimeout(welcome, 1000);
console.log("D");
Firstly, when we enter the program, global() is passed to the call stack.
Then line 1, the console statement will be passed to the call stack and it will be executed.
The welcome function will be stored in the memory heap and after that setTimeout will be passed to the call stack. Since there is a wait time of 1 sec it will be passed to the WEB API so that it can be handled in the background and code blocking doesn’t occur.
After that, the next console statement will be added to the call stack, executed, and removed from the call stack.
After completion of 1 sec, the setTimeout will be passed to the task queue/callback queue.
Now, the event loop will check if the call stack is empty or not. If it is empty then it will pass the seTimeout from the callback queue to the call stack, the “welcome()” function will be read from the memory heap and then will get executed.
We have to note that the delay passed in the setTimeout is the minimum delay, that is if we pass 1000 ms then the program will wait at least 1000 ms before executing it but it can be more because it might be possible that the call stack is not empty at that time.
‘setTimeout’ & ‘setInterval’ in JavaScript
setTimeout()
The ‘setTimeout’ function is the most commonly used method in JavaScript for executing a function after a specified time. It allows us to delay a task making it useful for handling asynchronous operations.
Syntax:
setTimeout(function,delay);
function: code that we want to execute after the delay. We can also pass the arrow function with parameters or we can even pass callback functions.
delay: the time passed in milliseconds to wait before executing the function.
Example:
setTimeout(function() {
console.log("A");
//we will get A on console after 2 seconds
}, 2000);
setInterval()
The ‘setInterval’ function in JavaScript is used when we want to repeatedly execute a function or specific lines of code at a fixed time interval. It is useful when we want to operate again and again like updating time or creating an animation.
While using setInterval we must store it in a variable because setInterval will make the code passed to it get stuck in the browser and it will keep on executing it again and again until explicitly stopped. That’s why we also need to use the ‘clearInterval’ function and pass that variable to it.
Syntax:
const variable_name = setInterval(function,interval);
clearInterval(varibale_name);
function: the function or the lines of code we want to execute repeatedly.
interval: the time in milliseconds between each execution.
Example:
const interval = setInterval(() =>{
console.log("A");
//After every 1 sec A will be printed on console
},1000);
setTimeout(() => {
clearInterval(interval);
console.log("Interval stopped.")
}, 15000);
// interval will be stopped after 15 seconds
Callbacks in JavaScript
Callbacks were the original method to handle asynchronous operations in JavaScript. A callback is a function that is passed as arguments to another function which then can be executed by that function later. Callbacks can be used to handle asynchronous operations like fetching data, handling events, or executing code after a delay.
How callbacks work is that when we pass a function as a callback to another function, we are passing a reference of that function to another function without invoking it immediately. Then callback is executed after the function it is passed to is executed or whenever the function receiving the callback deems appropriate.
Example:
function greet(name, callback) {
console.log("Hello, " + name + "!");
callback();
}
function sayGoodbye() {
console.log("Goodbye!");
}
greet("Abhishek", sayGoodbye);
Here, ‘greet’ is a function that takes a name and a function callback as an argument. And ‘goodbye’ is the function that is passed as an argument to the function ‘greet’.
Problems with Callback - Callback Hell
When too many nested callback functions depend on each other to execute based on the results provided by previous functions, the code can become deeply nested, difficult to read, and prone to errors. This pyramid-like structure is commonly referred to as "Callback Hell."
Example:
doTask1(function(result1) {
doTask2(result1, function(result2) {
doTask3(result2, function(result3) {
// And so on...
});
});
});
To tackle this problem in modern JavaScript, we use promises and async/await syntax that provide a more readable way to handle asynchronous operations.
Promises in JavaScript
In modern JavaScript, asynchronous operations are handled with promises. When handling several asynchronous processes that depend on each other, they offer a more organized and controllable solution than callbacks.
What is a Promise?
A promise is an object that represents the eventual completion or failure of an asynchronous operation. A promise has three stages:
Pending: The initial state of a promise that is neither rejected nor fulfilled. It shows that the asynchronous operation associated with the promise is not yet completed.
Fulfilled: When the operation is completed successfully and the promise now has a result.
Rejected: It shows that the asynchronous operation associated with the promise has failed. The promise is then considered ‘settled’ with an error reason provided for the failure of the promise.
Creating a Promise
For creating a promise we use the Promise constructor which takes a single callback function with two arguments: resolve and reject.
Resolve: resolve is called when everything in the promise went well and the operation was successful.
Reject: reject is called when we face an error in the operation due to which it failed.
const myPromise = new Promise((resolve, reject) => { const success = true; // Simulate success or failure if (success) { resolve("Operation was successful!"); } else { reject("Operation failed."); } });
The output from the promise can be accessed using the ‘.then()’ and the ‘.catch()’ methods.
‘promise.then()’: this method is called when the promise is fulfilled. It takes a callback function which can be used to handle the resolved output from the promise.
‘promise.catch()’: this method is used when the promise is rejected due to some error. It takes a callback function as a parameter and handles the error we get from the promise.
myPromise.then((result) => { console.log(result); // "Operation was successful!" }).catch((error) => { console.error(error); // "Operation failed." });
Chaining Promises
We can chain promises together making it easier to handle sequential asynchronous operations. We execute multiple asynchronous operations one after the other where each operation depends on the completion of the previous one this is done using the ‘.then()’ handler.
When we return a value or a promise from the ‘.then()’ handler it is automatically passed to the next ‘.then()’ handler in the chain. If the value returned is not a promise then the promise is resolved.
const firstPromise = new Promise((resolve) => {
setTimeout(() => resolve(1), 1000);
});
firstPromise
.then((result) => {
console.log(result); // 1
return result * 2;
})
.then((result) => {
console.log(result); // 2
return result * 3;
})
.then((result) => {
console.log(result); // 6
})
.catch((error) => {
console.error("Error:", error);
});
Handling Multiple Promises
JavaScript provides different methods to handle multiple promises simultaneously.
Promise.All(): This method is used when we want to execute different promises and access their data together. It waits for all the promises to be fulfilled and returns an array of their results. If any one of the promises is rejected then the ‘promise.all()’ is rejected.
const promise1 = Promise.resolve(3); const promise2 = 42; const promise3 = new Promise((resolve) => setTimeout(resolve, 100, 'foo')); Promise.all([promise1, promise2, promise3]).then((values)=> { console.log(values); // [3, 42, "foo"] });
Promise.race(): this method return the result of the first promise that is either fulfilled or rejected from the list of promises passed.
const promise1 = new Promise((resolve) => setTimeout(resolve, 500, "one")); const promise2 = new Promise((resolve) => setTimeout(resolve, 100, "two")); Promise.race([promise1, promise2]).then((value) => { console.log(value); // "two" (because promise2 resolves first) });
Async and Await
‘async’ and ‘await’ are modern javascript features that provide a more readable and convenient way to work with promises. It allows us to write asynchronous code in a synchronous-looking manner making it easier to follow and debug.
Working with async and await
‘async’: The async keyword is used to declare an asynchronous function, indicating that it will return a promise. The result returned by the function is wrapped in the resolved promise.
‘await’: The await keyword can only be used inside an async function. It pauses the execution of the function until the promise it is waiting for gets settled either fulfilled or rejected. The ‘await’ keyword can only be used with promises, letting you wait for the promises to resolve making the code execution look synchronous.
Syntax:
async function my_function(){ try{ let value = await promise_based_operation(); return value; }catch(error){ //it will catch the reject from promise // and error is handled } }
In async functions, the resolve and reject from promise are handled using try and catch block.
Example:
async function fetchData() { try { const data = await new Promise((resolve,reject)=> { setTimeout(() => reject("Failed to fetch data"), 2000); }); console.log(data); } catch (error) { console.error("Error:", error); // "Error: Failed to fetch data" } } fetchData();
The fetchData function is declared as async, meaning it returns a promise.
Inside fetchData, await is used to wait for the promise returned by setTimeout to resolve.
Once the promise is resolved with the value "Data fetched", it is logged into the console.
If the promise is rejected, the code inside the try block will throw an error, and the catch block will handle it.
This allows for more straightforward error handling compared to traditional promise chains.
Conclusion
Asynchronous JavaScript is a powerful feature that allows developers to write efficient, non-blocking code. Understanding how to use callbacks, promises, and async/await effectively is essential for modern JavaScript development. By mastering these concepts, you can write more readable, maintainable, and performant code, ensuring a better user experience in your applications and smooth performance under heavy loads.
Subscribe to my newsletter
Read articles from Abhishek Sadhwani directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by