Asynchronous JavaScript: The TL;DR Version You'll Always Recall
Aditya Bhattad
Posted on July 15, 2024
I've noticed that async JavaScript is a topic of importance in many frontend and full-stack interviews. So rather than having to open docs and other 100s of resources before each interview or whenever I need to implement it, I decided to create a comprehensive resource ones and for all. The result? This blog.
In this blog post, I have included all the things I knew about async Javascript. So without a further ado, let's get started🚀
Introduction to Asynchronous JavaScript
To understand asynchronous programming, we first need to understand synchronous programming.
Synchronous Example:
console.log("Vivek loves Javascript");
console.log("Vivek is a frontend dev");
console.log("Vivek wants to learn async Javascript");
In this example, the browser executes each line sequentially, waiting for each console.log
statement to complete before moving to the next. This approach works fine for quick operations but can cause problems with time-consuming tasks.
Take this inefficient factorial calculator:
If you input a large number and click "Check to find out", the program freezes temporarily, making the page unresponsive. This happens because JavaScript, in its basic form, is synchronous, blocking, and single-threaded language in its most basic form. When calculateFactorial
is called, it occupies the single thread, preventing any other code from executing until it returns.
Making the Program Responsive
To make our program more responsive, it should:
- Start a long-running operation by calling a function.
- Have the function initiate the operation and return immediately, allowing the program to remain responsive to other events.
- Execute the operation in a way that doesn't block the main thread.
- Notify us with the result when the operation eventually completes.
Asynchronous Functions
Asynchronous functions allow a program to initiate a time-consuming task and remain responsive to other events while that task runs. The program can continue executing other code and receive the result once the task completes.
In the following sections, first we will explore how to use them, and then at the end we will take a look at how they work behind the scene.
Timeout and Interval
Let's start with the basics of async programming and build from there.
setTimeout
The setTimeout function executes a block of code once after a specified time has elapsed.
Parameters:
- A reference to the function to be executed.
- The time (in milliseconds) before the function will be executed.
- Optional parameters to pass to the function when executed.
Function Signature:
setTimeout(function, duration, param1, param2, ...);
To cancel a timeout, you can use the clearTimeout()
method, passing in the identifier returned by setTimeout
as a parameter.
Here's how you can use clearTimeout
:
const timeoutId = setTimeout(() => {
console.log('hello');
}, 100);
clearTimeout(timeoutId);
// Expected output: (nothing)
A more practical scenario for clearing timeouts is in React when a component gets unmounted. We can use clearTimeout
to cancel the timeout used in that component, freeing up resources.
setInterval
setInterval
is similar to setTimeout
, with one key difference: it executes repeatedly at the specified interval, continuing indefinitely until cleared.
Example:
const intervalId = setInterval(() => {
console.log('hello, from setInterval!');
}, 100);
clearInterval(intervalId);
Notes:
Timers and intervals are not part of JavaScript itself but are implemented by the browser (client-side) and Node.js (server-side). setTimeout and setInterval are names given to this functionality in JavaScript.
You can achieve the same effect as setInterval with a recursive setTimeout:It's also possible to achieve the same effect as
setInterval
with a recursivesetTimeout
:
function run() {
console.log("I will also run after a fixed duration of time, just like I would have if it was setInterval.");
setTimeout(run, 100);
}
setTimeout(run, 100);
Callback Functions
Definition and purpose
In JavaScript, functions are first-class objects, meaning they can be:
- Assigned to variables
- Passed as arguments to other functions
- Returned from functions This ability to pass functions as arguments is what enables callback functionality. Any function that is passed to another function is called a callback function. The function which accepts another function as an argument or returns another function is called a higher-order function.
With setTimeout and setInterval, we pass a callback to these functions, making them higher-order functions.
Take another simple example:
// This is an example of a callback function.
function greet(name) {
console.log(`Hello, ${name}!`);
}
// This is an example of a higher-order function.
function greetVivek(greetFn) {
greetFn("Vivek");
}
greetVivek(greet);
In this example, greet is a callback function in the context of greetVivek. Since greetVivek takes a function as input, it is considered a higher-order function.
Synchronous vs. Asynchronous Callbacks
- Synchronous Callback: Executes immediately, like in the example above.
- Asynchronous Callback: Executes after an asynchronous operation completes, delaying execution until a particular time or event, example: callback passed to setTimeout.
Callback Hell
When multiple callback functions depend on the result obtained from the previous level, it can lead to deeply nested code, making it difficult to read and maintain.
Example
getData(function(a) {
getMoreData(a, function(b) {
getMoreData(b, function(c) {
getMoreData(c, function(d) {
getMoreData(d, function(e) {
console.log('Where the hell am I??');
});
});
});
});
});
To solve this problem, promises were introduced, making asynchronous code easier to write and understand.
Promises
Introduction to Promises
MDN Definition:
A promise is a proxy for value not necessarily known when it is created, it allows us to associate handlers with an asynchronous actions eventual success value or failure reason.
In simple words:
A Promise is an object representing the eventual completion or failure of an asynchronous operation. It can be in one of three states:
- Pending: Initial state, neither fulfilled nor rejected.
- Fulfilled: The operation completed successfully.
- Rejected: The operation failed.
The eventual state of a pending promise can either be fulfilled with a value or rejected with a reason (error). When either of these states occur, the associated handlers queued up by the promise's then or catch method are called.
Example:
function buySandwich() {
return new Promise((resolve, reject) => {
if (Math.random() > 0.5) {
resolve('Here is your cheese sandwich!');
} else {
reject(new Error('Sorry, not enough bread left.'));
}
});
}
buySandwich()
.then((res) => {
console.log(res);
console.log("I love cheese sandwiches.");
})
.catch((err) => {
console.log(err.message);
console.log("Now I will have to cook pasta instead.");
})
.finally(() => {
console.log("Let’s go for a walk!");
});
If the promise has already been fulfilled or rejected when a handler is attached, the handler will still be called, so there is no race condition between an asynchronous operation and its handlers being attached.
const myPromises = Promise.resolve('Trust me bro!');
myPromise.then((value)=>{
console.log('Told, you!');
})
Here is nice diagram from MDN to understand it better
Chaining Promises
Since .then() and .catch() methods both return promises, they can be chained.
This functionality makes them better alternative of callbacks.
How promises can be use instead of callbacks
Callback Example
getData(function(a) {
getMoreData(a, function(b) {
getMoreData(b, function(c) {
getMoreData(c, function(d) {
getMoreData(d, function(e) {
console.log('Where the hell am I??');
});
});
});
});
});
Same function written using promises
getData()
.then(a => getMoreData(a))
.then(b => getMoreData(b))
.then(c => getMoreData(c))
.then(d => getMoreData(d))
.then(e => {
console.log('Here is the final result: ', e);
})
.catch(err => {
console.error('Something went wrong:', err);
});
Much more readable this way.
Error handling with Promises
There are two ways to handle errors with promises:
- Passing an
onRejected
handler as the second argument to.then()
: If we do this, the error won't be caught if it is thrown from theonFulfillment
handler.
myPromise.then(
result => { /* handle success */ },
error => { /* handle error */ }
);
- Passing an
onRejected
handler to a .catch() block: This ensures that even if theonFulfillment
handler throws an error, it is caught by the .catch() and can be handled there.
myPromise
.then(result => { /* handle success */ })
.catch(error => { /* handle error */ });
The .catch()
method is generally preferred as it also catches errors thrown in the .then()
handlers.
Static method for promises
Promise.all()
The Promise.all()
method takes an iterable of promises as input and returns a single Promise that resolves to an array of the results of the input promises. The returned promise will resolve when all of the input's promises have resolved, or if the input iterable has no promises. It rejects immediately if any of the input promises reject or if a non-promise throws an error, and will reject with the first rejection message/error.
Example
const promise1 = Promise.resolve(3);
const promise2 = 42;
const promise3 = new Promise((resolve,reject)=>{
setTimeout(resolve,100,'foo');
})
Promise.all([promise1,promise2,promise3]).then((values)=>{
console.log(values);
})
// expected output Array [3,42,'foo']
Promise.allSettled()
Slight varaition of Promise.all()
, Promise.allSettled()
waits for all input promises to complete regardless of whether they resolve or reject. It returns a promise that resolves after all of the given promises have either resolved or rejected, with an array of objects that each describe the outcome of each promise.
Example:
const promise1 = Promise.reject("failure");
const promise2 = 42;
const promise3 = new Promise((resolve) => {
setTimeout(resolve, 100, 'foo');
});
Promise.allSettled([promise1, promise2, promise3]).then((results) => {
console.log(results);
});
// expected output: Array [
// { status: "rejected", reason: "failure" },
// { status: "fulfilled", value: 42 },
// { status: "fulfilled", value: 'foo' }
// ]
Promise.any()
Promise.any()
takes an iterable of promises as input and returns a single Promise. This returned promise fulfills when any of the input promises fulfill, with the first fulfillment value. It rejects when all of the input's promises reject (including when an empty iterable is passed), with an AggregateError containing an array of rejection reasons.
Example
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));
// expected output: "quick"
Promise.race()
The Promise.race() method returns a promise that fulfills or rejects as soon as one of the input promises fulfills or rejects, with the value or reason from that promise.
Example
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 resolves but promise2 is faster.
})
// expected output: 'two'
Most famous usage of Promise.race()
: to implement timeouts for async function, that is if the async functions take too long we can suspend it.
function promiseWithTimeout(promise,duration){
return Promise.race(
[
promise,
new Promise((_,reject)=>{
setTimeout(reject,duration,"Too late.")
})
]
)
}
promiseWithTimeout(new Promise((resolve,reject)=>{
setTimeout(resolve,4000,"Success.")
}),5000).then((result)=>{
console.log(result)
}).catch((error)=>{
console.log(error)
})
Async/Await
Introduction to async/await
From the above sections, it's clear that chaining promises solves the problem we had with callback hell. However, there is an even better way to handle asynchronous operations: using the async
and await
keywords introduced in ES2017 (ES8). These keywords allow us to write code that looks synchronous while performing asynchronous tasks behind the scenes.
Async functions
The async
keyword is used to declare async functions. Async functions are instances of the AsyncFunction
constructor. Unlike normal functions, async functions always return a promise.
Normal function
function greet() {return "hello"}
greet()
// expected output: hello
Async function
async function greet() {return "hello"}
greet()
We can also explicitly return a promise:
async function greet() {
return Promise.resolve("hello")
}
greet()
// expected out (same for both): Promise{<fulfilled>:"hello"}
We can use .then()
to get actual result.
greet().then((res)=>{
console.log(res);
})
// expected output: "hello"
The real advantage of async functions is when we use them with the await keyword.
The await keyword
The await keyword can be placed in front of any async promise-based function to pause your code execution until that promise settles and returns its result. Note that the await keyword only works inside async functions, so we cannot use await inside normal functions.
Example
async function greet(){
let promise = new Promise((resolve,reject)=>{
setTimeout(()=>resolve("hello"),1000)
})
let result = await promise;
console.log(result);
}
greet()
// expected output: "hello" (after 1 second)
Chaining Promises vs Async/Await
Here is the same function written with promises as well as async/await:
Using Promises
getData()
.then(a => getMoreData(a))
.then(b => getMoreData(b))
.then(c => getMoreData(c))
.then(d => getMoreData(d))
.then(e => {
console.log('Here is the final result: ',e);
})
.catch(err => {
console.error('Something went wrong:', err);
});
Using Async/Await
async function getData() {
try {
const a = await getData();
const b = await getMoreData(a);
const c = await getMoreData(b);
const d = await getMoreData(c);
const e = await getMoreData(d);
console.log('Here is the final result: ', e);
} catch (err) {
console.error('Something went wrong:', err);
}
}
Even error handling becomes much simpler with async/await.
Sequential vs Concurrent Execution
To improve the performance of web applications, we can use all the concepts we've learned above. Normally, when making asynchronous function calls one after another, the requests are blocked by the previous request, referred to as a request "waterfall," as each request can only begin once the previous request has returned data.
Sequential Execution
// Simulate two API calls with different response times
function fetchFastData() {
return new Promise(resolve => {
setTimeout(() => {
resolve("Fast data");
}, 2000);
});
}
function fetchSlowData() {
return new Promise(resolve => {
setTimeout(() => {
resolve("Slow data");
}, 3000);
});
}
// Function to demonstrate sequential execution
async function fetchDataSequentially() {
console.log("Starting to fetch data...");
const startTime = Date.now();
// Start both fetches concurrently
const fastData = await fetchFastData();
const slowData = await fetchSlowData();
const endTime = Date.now();
const totalTime = endTime - startTime;
console.log(`Fast data: ${fastData}`);
console.log(`Slow data: ${slowData}`);
console.log(`Total time taken: ${totalTime}ms`);
}
fetchDataSequentially()
/*
expected output:
Starting to fetch data...
Fast data: Fast data
Slow data: Slow data
Total time taken: 5007ms
*/
Concurrent Execution:
async function fetchDataConcurrently() {
console.log("Starting to fetch data...");
const startTime = Date.now();
// Start both fetches concurrently
const fastDataPromise = fetchFastData();
const slowDataPromise = fetchSlowData();
// Wait for both promises to resolve
const [fastData, slowData] = await Promise.all([fastDataPromise, slowDataPromise]);
const endTime = Date.now();
const totalTime = endTime - startTime;
console.log(`Fast data: ${fastData}`);
console.log(`Slow data: ${slowData}`);
console.log(`Total time taken: ${totalTime}ms`);
}
/*
expected output:
Starting to fetch data...
Fast data: Fast data
Slow data: Slow data
Total time taken: 3007ms
*/
In the concurrent execution, both requests are fired off simultaneously, and we await them using Promise.all()
. As the requests are called concurrently, no request has to wait for the other, resulting in faster overall execution.
JavaScript Event Loop
Now that we have seen what promises are and how to use them, it is always good to understand how they work. As I mentioned earlier, JavaScript is a synchronous, blocking, and single-threaded language. The JavaScript engine has its own provisions to execute async code. Several different components come together to make async code execution possible.
Call Stack
The call stack is where the code executes line by line. The execution pointer starts from the top, pushing functions to be executed line by line onto the call stack and popping them out once they return.
Web APIs
These are provided by the browser in client-side JavaScript and by Node.js in server-side JavaScript. When there is any asynchronous task to be executed, it is passed to Web APIs, which are responsible for executing them. This offloading of asynchronous tasks allows the browser to execute other operations and prevents it from freezing.
Callback Queue
This is a queue data structure. Whenever setTimeout
or setInterval
needs to be called after a particular duration, the Web APIs cannot directly push the code to the call stack as it would pause the current execution of the call stack, potentially leading to unexpected results. To avoid this, there is a buffer-like zone, so all the callbacks to be executed go from Web APIs to the callback queue before reaching the call stack.
Microtask Queue
Similar to the callback queue but used for promises (It is given greater priority than the callback queue).
How the event loop works
Synchronous Code
First, let's start by seeing how the event loop works for normal synchronous code. Consider the following code:
function A() {
console.log("A");
}
function B() {
console.log("B");
}
function C() {
console.log("C");
}
A();
B();
C();
As the execution pointer starts from the first line, function A
gets pushed to the stack, is executed, and then popped off the stack. The same thing happens with B
and then C
. All this happens sequentially. Here nothing other that call stack and memory heap are included.
Asynchronous Code
But when there is asynchronous code included, the JavaScript engine cannot handle these by itself. This is where the Web APIs, event loop, task queue, and microtask queue come into play. Let's visualize the execution flow of code that includes setTimeout
:
function A() {
console.log("A");
}
setTimeout(function B() {
console.log("B");
}, 1000);
function C() {
console.log("C");
}
A();
C();
Here, as usual, the execution pointer starts from the first line, pushes A
onto the stack, executes it, and pops it off. After this, setTimeout
is pushed to the call stack. The callback function along with the timer is passed to the Web APIs to handle, and setTimeout
is popped off the stack. The function C
is then pushed to the stack, executed, and popped off. When the time defined in setTimeout
elapses, the callback function is passed to the task queue. The event loop keeps checking if there is anything in the task queue and call stack. If there is anything in the task queue and the call stack is empty, the function in the queue is passed to the call stack, where it is executed as normal synchronous code.
Promises
Let's go through the code that includes a promise:
function A() {
console.log("A");
}
const promise = new Promise((resolve) => {
setTimeout(() => resolve("B"), 1000);
});
promise.then((res) => {
console.log(res);
});
function C() {
console.log("C");
}
A();
C();
Here, as usual, function A
is pushed to the stack, gets executed, and pops off. Then the promise object is created and passed to the memory heap, and the async code is passed to Web APIs to be executed. Concurrently, the execution pointer moves to the next line, and when it scans the .then()
, it assigns the callback passed to the then to the resolve value of the promise. Then it pushes function C
to the call stack, executes it, and pops it off. Once the async code is done executing, the callback along with the returned value is passed to the microtask queue. The event loop keeps polling the call stack, and when the call stack is empty, it moves the callback along with the value to the call stack, where it gets executed.
Resources:
Asynchronous JavaScript Crash Course: https://youtu.be/exBgWAIeIeg?si=ccrAcUXnQS0gJgWE
MDN Docs: https://developer.mozilla.org/en-US/docs/Learn/JavaScript/Asynchronous
Conclusion
Thank you for taking the time to read this blog post till the finish. I have plans to start a series where I share about what I did as a developer or anything I learned throughout the week. I am planning to write weekly, so if you find this blog interesting, make sure to keep an eye out for future posts.
Plus, if you have any feedback, or corrections, please let me know. Your input is valuable and helps improve the content for everyone.
Have a great day✨!
Posted on July 15, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.