The JavaScript Paradox: Synchronously Asynchronous
"JavaScript is a synchronous, single-threaded language" - a line we have heard numerous times. Let's dive deep into it and understand the duality of JavaScript where it inherently is a synchronous language but can perform operations asynchronously as well.
Due to its synchronous nature, JavaScript runs one operation at a time, preventing the execution of other code until it is finished. JavaScript can now effectively execute non-blocking operations after the commencement of asynchronous programming paradigms, which has challenged this notion. JavaScript can execute tasks asynchronously while keeping its single-threaded structure by using mechanisms like promises, callbacks, and async...await syntax.
Let's understand more about it by first understanding how JavaScript code is executed and then the difference between synchronous and asynchronous in JavaScript.
JavaScript Code Execution
JavaScript code passes through several steps before finally being executed. These steps are:
Parsing the code and breaking it down into small tokens, this is done by a parser,
Then an Abstract Syntax Tree(AST) is created, which basically represents its structure,
Once AST is created, the JS engine interprets the code while optimizing it at the run time, this is called Just In Time(JIT) compilation,
This code is then executed,
Additionally, a garbage collection mechanism is also working to manage memory.
Synchronous & Asynchronous in JavaScript
Synchronous and asynchronous refer to the ways in which code is run and interacts with other JavaScript operations.
Synchronous operations execute sequentially, blocking subsequent code execution until the current operation completes. This way the code is executed in a subsequent manner. For example:
console.log('Step 1');
console.log('Step 2');
console.log('Step 3');
//Output: 1 2 3
Asynchronous operations on the other hand do not necessarily execute in sequence, thus they don't block the execution of subsequent code. They simply allow other operations to continue while waiting for the asynchronous task to get completed. Asynchronous operations in JavaScript are carried out by using - callback functions, promises, and async-await. Example:
console.log(1);
setTimeout(() => {
console.log(2);
}, 1000);
console.log(3);
// Output: 1 3 2
JS Runtime Environment Terminologies
Since we have understood how JS code is executed behind the scenes. It is important to understand how JS code is managed in its runtime environment.
Call Stack
Firstly the code goes inside the call stack which is a mechanism by which the JavaScript engine keeps track of function calls in a program. It operates as a Last In, First Out (LIFO) data structure, meaning that the last function called is the first one to be resolved. There is only one call stack as JavaScript is single-threaded meaning only one thread for execution of code is available.
Event Loop
It is a JavaScript mechanism that is responsible for managing tasks from task queue and callback queue. Event loop has the responsibility to check if the call stack is empty then it sends tasks waiting in the two available queues to the call stack for execution based on their priority.
Callback Queue
The JavaScript runtime uses a mechanism called the Callback Queue or the Task Queue to manage asynchronous tasks. The callback functions are stored in a FIFO data structure and are awaiting execution. In order to make sure that callback functions are carried out as soon as the call stack is empty, the Event Loop keeps an eye on both the call stack and the callback queue.
Microtask Queue
Microtask Queue gets the callback functions coming through Promises and Mutation Observer. In terms of retrieving the callback functions for the Event Loop, the Microtask Queue is prioritized over the Callback Queue.
Web APIs
JavaScript code can interact with the browser environment and carry out different tasks only due to the functionality provided by web browser APIs (Application Programming Interfaces). Developers are able to manipulate web pages, handle user input, make network requests, and perform other tasks by using the methods, properties, and events that these APIs provide. Some of these are- fetch, setTimeout, DOM APIs, geolocation APIs, etc.
Here are some code snippets of how these APIs are used:
setTimeout()
setTimeout(() => {
console.log('Execute after 2 seconds');
}, 2000);
fetch()
fetch('https://api.example.com/data')
.then(response => response.json())
.then(data => console.log(data));
Callback Functions
One of the core components in asynchronous JavaScript is the use of callback functions. Callback functions are functions passed as arguments to other functions and are called once the operation is complete. The functions that take callback functions as arguments are called Higher Order Functions.
Callback functions are one the ways to facilitate asynchronous programming but it has some banes as well. They are-
Inversion of Control: It is a concept where the control of the program is transferred to an outer or third-party program, where the host or user loses control over their program and thus cannot handle its flow.
Callback Hell: Callback hell common problem when dealing with asynchronous operations, it refers to the nested structure of callback functions, leading to unreadable and unmaintainable code.
getData(function(data1) { processData(data1, function(data2) { processDataAgain(data2, function(data3) { // More nested callbacks }); }); });
To tackle the problem of callback hells, JavaScript developer use certain techniques like:
Promises
async-await
Promises
A promise is an object representing the eventual completion or failure of an asynchronous operation. It allows asynchronous code to be written in a more synchronous fashion, enhancing readability and maintainability. Promises have three states: pending, fulfilled, and rejected.
Promises solve the problems of callback hell by making the code more readable and developer friendly and inversion of control by providing then() method to call the function which returns back the control to the user. One can create a promise by using "new" keyword along with "Promise" constructor which takes a function along with two parameters- reject and resolve. Inside this function, asynchronous tasks are performed, and the result is either resolved or rejected based on the outcome of the operation.
const myPromise = new Promise((resolve, reject) => {
setTimeout(() => {
resolve("foo");
}, 3000);
});
Methods in Promises
Promise.all(): It takes an iterable like array of promises as input and returns a single promise that resolves when all of the promises in the iterable have resolved, or rejects as soon as one of the promises rejects.
const p1 = Promise.resolve(23); const p2 = new Promise((resolve, reject) => { setTimeout(resolve, 100, 'Hello'); }); Promise.all([p1, p2]).then((values) => { console.log(values); }); // Output: Array [23, "Hello"]
Promise.allSettled(): It takes an iterable like array of promises as input and returns a single promise that resolves after all of the input promises have settled (either fulfilled or rejected).
const p1 = Promise.resolve(3); const p2 = new Promise((resolve, reject) => setTimeout(reject, 2000, 'Hii'), ); const promises = [p1, p2]; Promise.allSettled(promises).then((results) => results.forEach((result) => console.log(result.status)), ); // Output: // "fulfilled" // "rejected"
Promise.any(): It takes an iterable like array of promises as input and returns a single promise that resolves as soon as any of the promises in the iterable fulfills. If all the input promises reject, it rejects with an aggregate error containing all the rejection reasons.
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)); // Output: "quick"
Promise.race(): It takes an iterable like array of promises as input and returns a single promise that resolves or rejects as soon as one of the promises in the iterable resolves or rejects.
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 resolve, but promise2 is faster }); // Output: "two"
Error Handling in Promises
Errors in promises can be handled using the .catch() method by chaining it to the promise variable or .then() method which handles the execution of promise.
const promise = new Promise((resolve, reject) => {
throw new Error('Error Occurred');
});
promise.catch((error) => {
console.error(error);
});
// Expected output: Error: Error occurred!
Async...Await
Async...await is a syntactic sugar built on top of promises, offering a more concise and readable way to write asynchronous code. The async keyword is used to declare an asynchronous function, while await is used to pause execution until a promise is resolved or rejected.
async function logMovies() {
const response = await fetch("http://example.com/movies.json");
const movies = await response.json();
console.log(movies);
}
After getting to know about promises and async...await, the main question that hampers our mind is "What is the difference between the two??"
Well for instance we can say that one of the main differences between the two would be that async...await offers a more concise and readable syntax by eliminating the need for explicit promise chaining. Async...await also simplifies error handling by allowing the use of try-catch blocks. Async...await is preferred for its simplicity, readability, and ease of use, especially in situations where multiple asynchronous operations need to be used sequentially. It also helps in avoiding the problem of nested promises.
Subscribe to my newsletter
Read articles from Gaurav Yadav directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by