Breaking the Chains of Synchronous Execution: Asynchronous Javascript
Photo by Joshua Aragon on Unsplash
Table of contents
Introduction:
In conventional (synchronous) programming, each participant had to wait for the work in front of them to be completed before beginning their own. This is considered to be bad if we talk in terms of user experience for a website user then Asynchronous programming came into the picture which breaks the chains of Synchronous execution. Everyone can start and perform their task simultaneously without waiting for the other program to be executed. In this blog, we are going learn about Asynchronous programming in Javascript along with various concepts and techniques that help us to execute Asynchronous tasks.
Understanding synchronous Programming?
Synchronous programming is the execution of the program in a way that one block of a program is executed at a particular time and the next block is executed when the first block is completed.
Imagine you're making a simple sandwich, and you have a set of sequential steps to follow:
Step one: Gather the ingredients. You need bread, cheese, lettuce, and tomato.
Step two: Spread mayonnaise on one slice of bread.
Step three: Place a slice of cheese on top of the mayonnaise.
Step four: Add lettuce on top of the cheese.
Step five: Place a slice of tomato on top of the lettuce.
Step six: Spread mustard on another slice of bread.
Step seven: Place the mustard-covered bread slice on top of the tomato slice.
Step eight: Cut the sandwich in half.
Step nine: Serve and enjoy!
In synchronous programming, each step is executed in order, and you wait for each step to complete before moving on to the next. For example, you gather the ingredients first, then spread mayonnaise on the bread, and so on. You can't move on to the next step until the previous one is finished.
However, in synchronous programming, there is no room for multitasking or parallel execution. You can't prepare the cheese while waiting for the bread to toast, for example. Each step must be completed before moving on to the next, resulting in a linear and often time-consuming process.
Now understand it with the help of an example:
function greet(name) {
console.log("Hello, " + name + "!");
}
function sayGoodbye(name) {
console.log("Goodbye, " + name + "!");
}
console.log("Before greetings");
greet("John");
sayGoodbye("John");
console.log("After greetings");
In this example, we have two functions: greet
and sayGoodbye
. The main program follows a synchronous execution flow:
It starts by printing "Before greetings".
It then calls the
greet
function with the name "John", which logs "Hello, John!" to the console.Next, it calls the
sayGoodbye
function with the name "John", which logs "Goodbye, John!" to the console.Finally, it prints "After greetings".
When you run this code, the output will be:
Before greetings
Hello, John!
Goodbye, John!
After greetings
In this synchronous scenario, each function call is executed one after another, and the program waits for each function to complete before moving on to the next statement. This ensures that the greetings and farewells are printed in the expected order.
Understanding Asynchronous Programming?
Imagine you're preparing a sandwich asynchronously with the help of a friend. Here's how it would go:
Step one: You ask your friend to gather the ingredients while you continue with the next steps.
Step two: You start spreading mayonnaise on one slice of bread while your friend simultaneously starts toasting the bread.
Step three: While you're placing a slice of cheese on top of the mayonnaise, your friend is washing lettuce and slicing tomatoes.
Step four: As you add lettuce to the sandwich, your friend adds the tomato slice.
Step five: While you're spreading mustard on the second slice of bread, your friend is getting the plate ready.
Step six: You finish spreading mustard, and your friend places the mustard-covered bread on top of the tomato slice.
Step seven: You take the sandwich and cut it in half, while your friend is preparing the table.
Step eight: You place the sandwich on the table, and your friend serves it.
Step nine: Both of you can now enjoy the sandwich together!
In the food-making example, you and your friend divide the tasks and work on them simultaneously. You can spread mayonnaise while your friend toasts the bread, and you can add cheese while your friend washes lettuce and slices tomatoes. This parallel execution saves time and enables you to complete the sandwich-making process more quickly.
Similarly, in asynchronous programming, you can initiate multiple tasks concurrently without blocking the main execution thread. This is achieved through mechanisms like callbacks, promises, or async/await syntax. It allows you to perform time-consuming operations, such as making API calls or reading large files, without stalling the entire program.
Now understand asynchronous programming with the help of an example:
console.log("Before async task");
setTimeout(() => {
console.log("Async task completed");
}, 2000);
console.log("After async task");
The main program follows an asynchronous execution flow:
It starts by printing "Before async task".
The
setTimeout
the function is called, which schedules the execution of the provided callback function after a specified delay (in this case, 2000 milliseconds or 2 seconds).The program moves on to the next statement without waiting for the asynchronous task to complete.
It prints "After async task".
After the specified delay, the callback function is executed, and it logs "Async task completed" to the console.
Before async task
After async task
Async task completed
In this asynchronous scenario, the program does not wait for the setTimeout
task to complete before moving on to the next statement. Instead, it continues executing the code and schedules the callback function to run after the specified delay.
Callbacks: The Foundation of Asynchronous Programming
Imagine you're hosting a party at your house, and you want to make sure everyone gets their snacks before the real fun begins. However, you're just one person, and you can't be everywhere at once. So, you come up with a brilliant plan to use callbacks to deliver the snacks efficiently.
You assign each party guest a unique callback phrase that they need to say when they're ready for their snacks. You start walking around the party with a tray of snacks, and whenever you hear someone shout their callback phrase, you rush over and give them their snack.
Here's how the code for this party snack delivery system could look:
function deliverSnacks(callback) {
console.log("Party host: Who's ready for snacks?");
callback();
}
function johnsCallback() {
console.log("John: Gimme my snacks, party host!");
}
function sarahsCallback() {
console.log("Sarah: I'm snack-hungry! Bring 'em on, party host!");
}
function bobsCallback() {
console.log("Bob: Snacks, please! I'm fading away here!");
}
// Party time!
console.log("Party begins!");
deliverSnacks(johnsCallback);
deliverSnacks(sarahsCallback);
deliverSnacks(bobsCallback);
console.log("Party continues!");
In this hilarious scenario, you (the party host) are simulating the behavior of a callback function. Each guest has their callback phrase, and when you hear it, you know it's time to deliver their snacks.
As the party host, you initiate the snack delivery by calling the deliverSnacks
function and passing in the guest's callback function as an argument. The deliverSnacks
function then announces its presence, and the guest's callback function is executed, resulting in the snack delivery.
The program follows an asynchronous execution flow:
The party is about to begin!
The host starts the
deliverSnacks
function and asks, "Who's ready for snacks?"John shouts his callback phrase, triggering his callback function.
Sarah shouts her callback phrase, triggering her callback function.
Bob shouts his callback phrase, triggering his callback function.
The party continues while everyone enjoys their snacks!
The end of the party.
Party begins!
Party host: Who's ready for snacks?
John: Gimme my snacks, party host!
Party host: Who's ready for snacks?
Sarah: I'm snack-hungry! Bring 'em on, party host!
Party host: Who's ready for snacks?
Bob: Snacks, please! I'm fading away here!
Party continues!
What is a Callback Hell?
Asynchronous JavaScript, while powerful, can sometimes lead to a notorious phenomenon known as "Callback Hell" or "Pyramid of Doom." This occurs when multiple nested callbacks make the code hard to read and maintain. Fear not! Promises come to the rescue, offering a more elegant and readable solution.
Imagine a scenario where we want to read a file, perform an asynchronous operation, and then write the result to another file. In a callback-centric approach, our code might look like this:
function receiveOrder(orderId, callback) {
// Simulating receiving an order asynchronously
setTimeout(() => {
const orderDetails = { id: orderId, flavor: 'Chocolate', size: 'Medium' };
callback(null, orderDetails);
}, 1000);
}
function prepareIceCream(flavor, size, callback) {
// Simulating preparing ice cream asynchronously
setTimeout(() => {
const preparedIceCream = { flavor, size, status: 'Prepared' };
callback(null, preparedIceCream);
}, 1000);
}
function serveIceCream(customer, callback) {
// Simulating serving ice cream asynchronously
setTimeout(() => {
console.log(`${customer.name}'s ${customer.order.flavor} ice cream is served!`);
callback(null, 'Served');
}, 1000);
}
// Nested callbacks for processing ice cream orders
receiveOrder(123, function (orderError, orderDetails) {
if (orderError) {
console.error('Error receiving order:', orderError);
} else {
prepareIceCream(orderDetails.flavor, orderDetails.size, function (prepareError, preparedIceCream) {
if (prepareError) {
console.error('Error preparing ice cream:', prepareError);
} else {
serveIceCream({ name: 'John Doe', order: preparedIceCream }, function (serveError, status) {
if (serveError) {
console.error('Error serving ice cream:', serveError);
} else {
console.log(`Order ${orderDetails.id} is complete. Status: ${status}`);
}
});
}
});
}
});
receiveOrder
Function:Simulates receiving an order asynchronously after a delay of 1 second.
Returns order details like flavor and size.
prepareIceCream
Function:Simulates preparing the ice cream asynchronously after a delay of 1 second.
Returns the prepared ice cream with its flavor, size, and status.
serveIceCream
Function:Simulates serving the ice cream to the customer asynchronously after a delay of 1 second.
Logs a message indicating the customer's name, ordered flavor, and the serving status.
Order Processing:
- We use nested callbacks to handle the asynchronous flow of receiving an order, preparing the ice cream, and serving it.
John Doe's Chocolate ice cream is served!
Order 123 is complete. Status: Served
The
receiveOrder
function is called with order ID123
. After a delay, it returns the order details, including the flavor ('Chocolate') and size ('Medium').The
prepareIceCream
function is called with the flavor and size obtained from the received order. After another delay, it returns the prepared ice cream with the provided details and a status of 'Prepared'.The
serveIceCream
function is called with the customer's name and the prepared ice cream details. After a final delay, it logs a message indicating that John Doe's Chocolate ice cream is served.The main callback in the original
receiveOrder
callback is executed, logging a message that Order 123 is complete with a status of 'Served'.
Promises: A solution to callback hell
Promises provide an elegant and more structured way to handle asynchronous operations.
A Promise is created using the Promise
constructor, which takes a function as an argument. This function, commonly referred to as the executor, has two parameters: resolve
and reject
.
const myPromise = new Promise((resolve, reject) => {
// Asynchronous operations go here
// If the operation is successful, call resolve with the result
resolve(result);
// If there is an error, call reject with an error object
reject(error);
});
Promise States
Pending: The initial state when the Promise is created.
Fulfilled: The state when the asynchronous operation is successful. The
resolve
function is called with the result.Rejected: The state when an error occurs during the asynchronous operation. The
reject
function is called with an error.
Handling Fulfillment and Rejection
The primary methods for handling fulfillment and rejection are .then()
and .catch()
.
.then()
The .then()
method is used to handle the fulfillment of a Promise. It takes a callback function as its argument, which will be executed when the Promise is fulfilled. It can be chained for sequential operations.
javascriptCopy codemyPromise.then((result) => {
// Code to handle the fulfillment (success) with the result
});
.catch()
The .catch()
method is used to handle the rejection of a Promise. It takes a callback function as its argument, which will be executed when the Promise is rejected. It can also be chained for error handling.
myPromise.catch((error) => {
// Code to handle the rejection (error) with the error object
});
Chaining Promises
One of the powerful aspects of Promises is their ability to be chained. This allows for sequential execution of asynchronous operations.
javascriptCopy codemyPromise
.then((result) => {
// Code for the first operation with the result
return anotherPromise;
})
.then((result) => {
// Code for the second operation with the result
})
.catch((error) => {
// Code to handle errors in any part of the chain
});
Let's reimplement the ice cream shop scenario using Promises, making the code more readable and maintainable.
javascriptCopy codefunction receiveOrder(orderId) {
return new Promise((resolve, reject) => {
setTimeout(() => {
const orderDetails = { id: orderId, flavor: 'Chocolate', size: 'Medium' };
resolve(orderDetails);
}, 1000);
});
}
function prepareIceCream(flavor, size) {
return new Promise((resolve, reject) => {
setTimeout(() => {
const preparedIceCream = { flavor, size, status: 'Prepared' };
resolve(preparedIceCream);
}, 1000);
});
}
function serveIceCream(customer) {
return new Promise((resolve, reject) => {
setTimeout(() => {
console.log(`${customer.name}'s ${customer.order.flavor} ice cream is served!`);
resolve('Served');
}, 1000);
});
}
// Using Promises for processing ice cream orders
receiveOrder(123)
.then((orderDetails) => prepareIceCream(orderDetails.flavor, orderDetails.size))
.then((preparedIceCream) => serveIceCream({ name: 'John Doe', order: preparedIceCream }))
.then((status) => console.log(`Order 123 is complete. Status: ${status}`))
.catch((error) => console.error('Something went wrong:', error));
receiveOrder
,prepareIceCream
,serveIceCream
Functions:- Each function now returns a Promise, encapsulating the asynchronous operations.
Processing Ice Cream Orders with Promises:
We chain Promises using the
.then
syntax, creating a sequential flow.The
.catch
block handles any errors that might occur during the process.
vbnetCopy codeJohn Doe's Chocolate ice cream is served!
Order 123 is complete. Status: Served
The
receiveOrder
Promise is fulfilled, passing the order details to the next.then
.The
prepareIceCream
Promise is fulfilled, passing the prepared ice cream details to the next.then
.The
serveIceCream
Promise is fulfilled, serving the ice cream and logging the message.The final
.then
block logs the completion message.
Summary
In this blog, we unraveled the intricacies of JavaScript's asynchronous nature, starting with the foundational concept of callbacks. We explored their role in deferring execution and their challenges, leading us to the elegant solution of Promises. With Promises, we discovered a structured and readable approach to handling asynchronous tasks, freeing us from the clutches of "Callback Hell."
But the journey doesn't end here! In our next blog, we'll delve into more advanced concepts, particularly focusing on the powerful async/await
syntax. Brace yourselves for an exploration of streamlined asynchronous code, error handling, and elevated mastery of JavaScript's concurrency. Stay tuned as we unveil the secrets of asynchronous programming and unveil the magic of async/await
in our upcoming blog!