Mastering Async JavaScript: Understanding The Nitty Gritty Parts
Before we dive into the fascinating world of asynchronous JavaScript, let's first take a peek behind the curtain to see how JavaScript operates under the hood. Understanding this will illuminate why asynchronous JavaScript is so crucial. So, buckle up and let's get started!
How Javascript Code is Executed
Just like a vehicle needs an engine to run, Javascript code also needs an engine to run, the Javascript Engine. There are different versions of Javascript engines, the most popular ones are the v8 engine run by Chrome, spider monkey run by Firefox, and so on.
To run a javascript code in a browser, the javascript engine alone is not used but a set of utilities and additional features provided by the browser is required. Imagine it like a car, a single engine doesn't make a whole car but along with the seats, doors, steering, and so on makes a complete car. All additional components along with the javascript engine create a Javascript runtime environment, where the code is executed.
How Javascript Runtime Works
(Credit:Luca Di Molfetta)
The Call stack:
The call stack works on the LIFO principle. The last function that enters the call stack is removed from the stack once the function has returned. Then the functions are removed from the stack accordingly. Remember, The call stack works Lexicographically, that is the function that comes first in the code will be added first in the stack.
Function Call: When a function is called, it’s added to the top of the call stack.
Execution: The engine executes the function.
Completion: Once the function completes, it’s removed from the stack.
Web APIs:
Web API's Provide additional functionality beyond basic JavaScript operations (e.g., timers, HTTP requests). They are not native to Javascript but provided by the browser.
Asynchronous Requests: When JavaScript code makes an asynchronous request (like
setTimeout
orfetch
), it’s handed off to the Web APIs. (This might not make sense now, Don't worry I've covered this in latter sections).- Completion: Once the Web API task is complete, it schedules a callback to be added to the callback queue.
Callback Queue
Purpose: Holds functions that are ready to be executed but must wait until the call stack is clear.
How It Works:
Task Completion: After a Web API task is completed, its callback function is added to the callback queue.
Execution: The event loop checks if the call stack is empty. If it is, it moves the callback function from the queue to the call stack for execution.
Synchronous Vs Asynchronous Javascript
From this, it is clear that the javascript executes code in a step-by-step manner from the call stack. Hence, JavaScript is a single-threaded language that executes the function based on its lexicographic position in the code. It waits for the current function to execute and return before jumping to the other function to carry it's operation. This behavior of javascript is known as Synchronous Javascript.
There's a twist to the story, synchronous Javascript can be ridiculously slow due to its single-threaded nature. Functions can take long time before returning which can delay the further execution of the code and hence lag in the system.
How Synchronous Code looks like:
console.log('Start');
function calculateSum(a, b) {
// Simulate a time-consuming operation
for (let i = 0; i < 1e8; i++) {} // Busy work
return a + b;
}
const result = calculateSum(5, 7); // This function blocks execution until it's done
console.log('Sum:', result);
console.log('End');
console.log('Start');
: This prints "Start" to the console.calculateSum(5, 7);
: This function is called with arguments 5 and 7. The function performs a busy work loop to simulate a time-consuming operation (e.g., a complex calculation or waiting). During this time, the JavaScript engine cannot do anything else.console.log('Sum:', result);
: OncecalculateSum
has completed and returned the result, this line prints the sum to the console.console.log('End');
: Finally, after everything else has finished, this line prints "End" to the console.
Point 2 is a classic example of the problem with synchronous javascript. It doesn't work in a non-blocking manner. It blocks the rest of the code until the current code is executed and completed. To curb this problem the concept of Asynchronous Javascript Exists.
Asynchronous code allows certain operations to occur without blocking the execution of other code. This is particularly useful for tasks that take time to complete, such as network requests or file operations. By using asynchronous techniques, you can ensure that your application remains responsive.
How Asynchronous Code looks like:
console.log('Start');
// Simulate an asynchronous operation using setTimeout
setTimeout(() => {
console.log('Timeout Callback');
}, 1000); // 1000 milliseconds = 1 second
console.log('End');
console.log('Start');
: This line prints "Start" to the console immediately.setTimeout(() => { console.log('Timeout Callback'); }, 1000);
: This sets up an asynchronous operation withsetTimeout
. The callback function insidesetTimeout
will be executed after 1000 milliseconds (1 second). However,setTimeout
it does not block the code execution; it schedules the callback and immediately continues to the next line (microtask queue).console.log('End');
: This line prints "End" to the console immediately after setting up the timeout, even though the timeout callback hasn’t yet been executed.
How Asynchronous Code Works Behind the Scene:
Remember the diagram at the start of the blog the asynchronous code is just like that, the only difference is there is an added feature in case of asynchronous code.
1. Call Stack
Keeps track of function calls and executes one function at a time.
2. Web APIs
Browser-provided APIs that handle tasks like network requests and timers without blocking the main thread.
3. Macrotask Queue (Task Queue)
Holds tasks (macrotasks) to be executed after the current stack is cleared. Includes setTimeout
and setInterval
.
4. Microtask Queue
Holds tasks (microtasks) to be executed immediately after the current task is completed but before any macrotasks. Includes promise callbacks.
5. Event Loop
Continuously checks the call stack and task queues to execute tasks in the right order.
How It Works:
Check if the call stack is empty.
Executes all microtasks in the microtask queue.
Executes tasks in the macrotask queue.
Repeats the process.
Great! so asynchronous code is good. But there are some caveats to using asynchronous code as well but that's out of the scope of this blog. Let us see how can we change our normal code to asynchronous code.
Ways To Convert Into Async Code:
Callbacks: Pass a function as an argument to another function, which will call it when the task is complete.
Promises: Represent a value that may be available now, or in the future, or never.
Async/Await: Syntactic sugar over promises, making async code look synchronous.
Callbacks:
- Functions passed as arguments to other functions, invoked when the asynchronous task completes. Essential for non-blocking code execution.
Example:
function fetchData(callback) {
setTimeout(() => {
callback('Data fetched');
}, 1000);
}
fetchData((data) => {
console.log(data);
});
What is Inversion of Control and Callback Hell?
Inversion of Control:
When the control over the execution of code is inverted, i.e., passed to another function or framework. This often happens with callbacks. In simpler terms: You hand over control to another function or library, allowing it to call your code (callback) when necessary.
function fetchData(callback) { setTimeout(() => { callback('Data fetched'); }, 1000); // Simulates an asynchronous operation with a 1-second delay } function processData(data) { console.log(data); } fetchData(processData); // Control is handed over to fetchData
fetchData
takes a callback functionprocessData
.setTimeout
simulates an async operation.The callback
processData
is called when the operation completes.Meanwhile, the processData is processing in the background, and the execution of another part of the code can be executed without blocking the whole process.
Here,
fetchData
controls whenprocessData
gets executed.In a real-world scenario, an API fetch call would resemble the setTimeout call.
Callbacks are a good way to handle asynchronous code until they get out of hand and you end up in the dreaded callback Hell.
Callback Hell:
- When multiple nested callbacks make the code hard to read and maintain. It occurs due to excessive use of callbacks.
Example of Callback Hell
Imagine a scenario where you need to perform several asynchronous tasks in sequence, with each task depending on the result of the previous one:
doSomething((result1) => {
doSomethingElse(result1, (result2) => {
doMore(result2, (result3) => {
doFinalThing(result3, (finalResult) => {
console.log(finalResult);
});
});
});
});
Why is Callback Hell Problematic?
1. Readability: The nested structure makes the code difficult to read and understand. 2. Maintainability: Making changes or debugging nested callbacks can be challenging.
3. Error Handling: Managing errors across multiple levels of callbacks is complex. Making a change in just one callback can bring an error in another callback.
To avoid callback hell other two features in asynchronous javascript can be used,
Promises and Async/await
Promises
What is a Promise and What Problem Does it Solve?
A promise is an object representing the eventual completion (or failure) of an asynchronous operation and its resulting value.
It allows you to write asynchronous code in a more readable and maintainable way compared to traditional callbacks.
Problems Solved:
Callback Hell: Promises help avoid deeply nested callbacks, known as "callback hell."
Error Handling: Promises provide a cleaner way to handle errors in asynchronous code.
Chaining: Promises make it easier to chain multiple asynchronous operations.
Different Stages in a Promise
Pending: Initial state, neither fulfilled nor rejected.
Fulfilled: The operation completed successfully, and the promise has a value.
Rejected: The operation failed, and the promise has a reason for the failure.
How to Create and Consume a Promise
Creating a Promise:
const myPromise = new Promise((resolve, reject) => {
setTimeout(() => {
resolve('Success!'); // or reject('Error!');
}, 1000);
});
Consuming a Promise:
myPromise
.then(result => {
console.log(result); // 'Success!'
})
.catch(error => {
console.error(error);
});
Chaining Promises Using.then
fetchData()
.then(data => processData(data))
.then(processedData => saveData(processedData))
.then(() => {
console.log('All tasks completed successfully');
})
.catch(error => {
console.error('An error occurred:', error);
});
Chaining: Each .then
returns a new promise, allowing for sequential execution of async tasks.
How to Handle Errors in Promises
Using.catch
:
- Attach a
.catch
method at the end of the promise chain to handle any errors.
Example:
fetchData()
.then(data => processData(data))
.catch(error => {
console.error('Error:', error);
});
Promise Based Functions:
Promise.all:
It Waits for all promises in an iterable to resolve or for any to be rejected.
Resolves when all included promises are resolved.
Rejects immediately upon any promise rejection.
Returns an array of resolved values.
Use Case: When you need to wait for multiple asynchronous operations to complete before proceeding or check if any one of the asynchronous calls fails.
Promise.race:
It Waits for the first promise to settle (either resolve or reject).
Resolves or rejects as soon as one of the promises settles.
Returns the value or reason of the first settled promise.
Use Case: When you need the result of the fastest promise, regardless of whether it resolves or rejects.
Promise.allSettled:
It Waits for all promises in an iterable to settle (either resolve or reject).
Resolves when all included promises have settled.
Returns an array of objects describing the outcome of each promise (whether it resolved or rejected).
Use Case: When you need to know the outcome of multiple promises regardless of their individual success or failure.
Promise.any:
It Waits for any of the promises to resolve.
Resolves as soon as one promise resolves.
Rejects only if all promises are rejected.
Returns the value of the first resolved promise.
Use Case: When you need at least one successful result from multiple promises, disregarding rejections unless all promises fail.
What is async/await ?
async/await:
- async/await is syntactic sugar over promises, allowing you to write asynchronous code that looks and behaves like synchronous code.
Why Use It:
Simplicity: Makes asynchronous code easier to read and write.
Error Handling: Use try/catch blocks for handling errors, similar to synchronous code.
Example:
async function fetchData() {
return new Promise((resolve, reject) => {
setTimeout(() => {
resolve('Data fetched'); // or reject('Error occurred');
}, 1000);
});
}
async function processAsyncData() {
try {
const data = await fetchData();
console.log(data); // 'Data fetched'
} catch (error) {
console.error('Error:', error);
}
}
processAsyncData();
async Function: Declares an async function that returns a promise.
await Keyword: Pauses the execution until the promise is resolved or rejected, making the code look synchronous.
Phew! We covered a lot in this blog. Some sections might need more research for mastery always feel free to check the mdn logs for more in-depth knowledge and never forget to keep practicing!
Subscribe to my newsletter
Read articles from Vishal Anthony directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by