DEV Community

Cover image for Asynchronous JavaScript: Promises vs. Async/Await in Details
doyinAdeyemo
doyinAdeyemo

Posted on

Asynchronous JavaScript: Promises vs. Async/Await in Details

Asynchronous programming is pivotal in modern web development, particularly in JavaScript. Traditionally, programming languages execute code sequentially from top to bottom. However, this synchronous execution model can lead to inefficiencies, especially when dealing with time-consuming operations such as fetching data from a server, accessing files, or performing complex computations. Asynchronous programming addresses these challenges by allowing certain processes to run independently of the main execution thread, thereby enhancing the responsiveness and performance of web applications.

Inherently single-threaded, JavaScript utilizes asynchronous programming to manage operations that would otherwise block the execution thread until completion. This is achieved through features like callbacks, promises, and async/await syntax, which help handle operations that are inherently uncertain in their completion time. The importance of mastering asynchronous programming in JavaScript cannot be overstated. It enables developers to create smoother, faster, more interactive web experiences. As websites and web applications become increasingly complex and data-driven, handling asynchronous operations effectively is crucial for maintaining performance and providing a seamless user experience.

In essence, asynchronous programming not only optimizes the performance of web applications by preventing the blocking of the main thread but also contributes significantly to the scalability and maintainability of the codebase. As we delve deeper into this subject, we will explore the mechanisms and patterns JavaScript provides to handle asynchronous operations and why they are indispensable in the toolkit of modern web developers.

Understanding Asynchronous Javascript

Imagine placing your order at the counter in a bustling coffee shop. Instead of waiting for your drink to be prepared, you sit and browse through a magazine. Meanwhile, the barista works on your order. Once your coffee is ready, the barista calls you to pick it up. This scenario is similar to how asynchronous operations work in JavaScript.

In JavaScript, asynchronous operations are like sending your order to the kitchen; you don’t have to stand and wait for the cook to finish. You can continue reading your book, chatting with a friend, or enjoying the music in the café. You will be notified once your order is ready, and you can enjoy your meal. Similarly, asynchronous JavaScript allows tasks like API calls or file operations to run in the background. Like you in the café, the main program doesn’t get blocked; it continues to run and respond to other user inputs or actions.

Common Asynchronous Operations in JavaScript

API Calls: These are like ordering food from a delivery service while watching a movie. You don’t pause the movie to wait for the food; you continue watching, and when the doorbell rings, you get your food. In web development, requesting a server for data works the same way. You ask for data and continue interacting with the site, and it's displayed to you once the data arrives.

fetch('https://jsonplaceholder.typicode.com/todos/1')
  .then(response => response.json()) // Convert the response to JSON
  .then(data => console.log(data))    // Log the data
  .catch(error => console.error('Error:', error)); // Handle any errors
Enter fullscreen mode Exit fullscreen mode

File Operations: This is akin to sending documents to print on a printer while you tidy up your desk. You don’t need to stand by the printer waiting for all your documents; you keep doing your other tasks. Similarly, file operations in JavaScript (especially on platforms like Node.js) let you initiate a file read or write operation and then move on to other tasks, receiving a notification when the operation is complete.

const fs = require('fs');

fs.readFile('example.txt', 'utf8', (err, data) => {
  if (err) {
    console.error('Error reading the file:', err);
    return;
  }
  console.log(data); // Log the contents of the file
});
Enter fullscreen mode Exit fullscreen mode

Timers and Delays: Using setTimeout() or setInterval() in JavaScript is like setting an oven timer when baking a cake. You set the timer and leave the oven to do its job while you whip up some frosting. The timer does not halt your other activities; it simply alerts you when to take the next step.

setTimeout(() => {
  console.log('This message appears after 2 seconds!');
}, 2000);
Enter fullscreen mode Exit fullscreen mode

Event Listeners: Imagine setting up a motion sensor in your garden that rings a bell when it detects movement. This is how event listeners work. You set them up to watch for certain events (like clicks or keystrokes), and they run associated functions in response without interfering with other operations of your application.

document.getElementById('myButton').addEventListener('click', () => {
  console.log('Button was clicked!');
});
Enter fullscreen mode Exit fullscreen mode

Promises

Imagine you're at a carnival and just tossed a ring towards a bottle, aiming to hook it. At that moment, three outcomes are possible: the ring lands perfectly (success), misses entirely (failure), or is still spinning in the air (pending). In JavaScript, this scenario is analogous to a Promise. A Promise is an object that represents the eventual completion or failure of an asynchronous operation. It’s like making a bet on whether the ring will land.

Anatomy of a Promise

Pending: The Promise is initially in the "pending" state. It's uncertain, like the ring spinning in the air.

Fulfilled: If the asynchronous operation completes successfully, the Promise is "fulfilled." Think of this as the ring landing on the bottle.

Rejected: If the operation fails or encounters an error, the Promise is "rejected." This is akin to the ring missing the target.

Creating a Promise Instance

Here is how you can create a promise for the above example:

const ringToss = new Promise((resolve, reject) => {
  let hasLanded = Math.random() > 0.5; // Random chance of success
  if (hasLanded) {
    resolve('You won a prize!'); // Fulfill the promise
  } else {
    reject('Try again!'); // Reject the promise
  }
});

console.log(ringToss); // Logs the Promise object showing its state
Enter fullscreen mode Exit fullscreen mode

Working with Promises

Now that you've tossed the ring, you need strategies to handle the outcome, whether a win or a miss.

Methods to Handle Promises

.then(): This method is used when the promise is fulfilled. It’s like claiming your prize at the carnival booth.

.catch(): This handles rejections or errors. It’s the equivalent of deciding what to do after you miss the ring toss.

.finally(): This method is for code that runs regardless of the outcome, similar to walking away from the booth after winning or losing.

Chaining Promises

Chaining promises is like playing several carnival games in a row. You must complete one game to receive a token that lets you play the next.

enterBooth()
  .then(token => playGameOne(token))
  .then(prize => tradeForToken(prize))
  .then(token => playGameTwo(token))
  .then(prize => console.log(`You won: ${prize}`))
  .catch(error => console.error('Game error:', error));
Enter fullscreen mode Exit fullscreen mode

In the example of chaining promises above, each step represents a sequential operation, each dependent on the success of the previous one. Here’s what happens in each step:

enterBooth(): This is likely the initial step where you "enter" the asynchronous operation. Imagine it as signing up or logging into an online service. This function returns a Promise.

.then(token => playGameOne(token)): Once you successfully enter, you receive a token. This token is then used to play the first game. This step is also a Promise, dependent on obtaining the token from enterBooth().

.then(prize => tradeForToken(prize)): If you win the first game, you receive a prize. This prize must be traded for another token to continue to the next game. This trading action is another asynchronous operation that returns a Promise.

.then(token => playGameTwo(token)): With the new token, you can play the second game. Again, this step is only possible if the previous step of trading the prize for a token is successful.

.then(prize => console.log(You won: ${prize})): If the second game is won, you will receive another prize. This prize is logged to the console, indicating the successful end of this promise chain.

You might be wondering when the .catch block comes into play. The .catch() block is invoked if any of the Promises in the chain fail or are rejected. This could happen if:

*You failed to enter the booth (enterBooth() fails).
*Any game along the way (playGameOne() or playGameTwo()) does not result in a prize.
*The prize cannot be traded for a token.

In any of these scenarios, the .catch() block catches the error, logs it or takes other corrective action. This prevents the error from stopping the entire script and allows for graceful error handling.

Final Words

Choosing between Promises and Async/Await largely depends on your project's specific needs. Async/Await might be the clearer choice for complex sequences of dependent operations due to its straightforward syntax and ease of error handling. Conversely, when dealing with multiple, simultaneous operations that do not depend on each other, utilizing Promises with techniques like Promise.all can significantly enhance performance. Both tools are essential in a JavaScript developer’s toolkit, empowering you to write more efficient, cleaner code.

Top comments (0)