Our Blog:

Async Magic: Journey into JavaScript's Non-blocking Power!


Middle
Andrej Vajagic

Andrej Vajagic

23.07.2023, 13:45

Read time: undefined mins

Async Magic: Journey into JavaScript's Non-blocking Power!

You should read the previous blog first to get better insights before reading this.

Synchronous and asynchronous are two fundamental ways in which JavaScript code can be executed. Let's go over the details of both.

Synchronous JavaScript:

In a synchronous programming model, tasks are executed one at a time. If a function relies on the result of a previous function, it has to wait for the previous function to complete its execution.

console.log('First');
console.log('Second');
console.log('Third');

The output will be:

First
Second
Third

This is because JavaScript, by default, is synchronous and single-threaded. The tasks are executed in the order they are written and each task must wait for the previous one to complete.

Asynchronous JavaScript:

In an asynchronous programming model, JavaScript allows the execution of the next task before the previous one is finished. This non-blocking nature is useful when dealing with tasks such as reading files, making API requests, or querying a database, where operations might take a considerable amount of time to respond.

Example:

code)

The output will be:

First
Third
Second

Even though the setTimeout function is set to zero delay, it is still placed in the callback queue and executed after all the synchronous code has run, therefore printing Second after Third.

Why Asynchronous?

Consider a scenario where you make a network request to fetch some data from a server. Network operations are slow compared to the execution of code. In a synchronous programming model, the entire application would freeze and stop responding while waiting for the server response. In an asynchronous model, the application can continue doing other tasks without freezing the UI or waiting for the server's response.

JavaScript uses various methods to handle asynchronous programming, like Callbacks, Promises, Async/Await, etc. These features allow us to write asynchronous code in a more synchronous manner, making it easier to understand and handle.

Callback

A callback is a function that is passed into another function as an argument and is executed after the outer function has completed its execution. This callback pattern is an inherent part of JavaScript, as JavaScript functions are first-class functions. That means they are treated like other variables.

Here's a simple example of a callback:

code)

In this example, the processUserInput function takes a function as an argument (the callback function), and this function is invoked as callback(name), passing in the user's name from the prompt.

Callbacks are often used for asynchronous programming. Here's an example using the setTimeout function:

code)

In this example, the callback function is passed to setTimeout, which will execute the function after 2 seconds. During this time, the rest of the code continues to run, so First and Third are printed before Second. This demonstrates the asynchronous, non-blocking nature of JavaScript.

Callback Hell

Callback hell refers to heavily nested callbacks that make the code hard to read and understand. This situation often arises when performing multiple asynchronous operations sequentially.

Here's an example:

code)

In this example, you can see that the callback functions are deeply nested, leading to what's colloquially called callback hell or the pyramid of doom. The deep nesting makes the code hard to read and error-prone. This is one of the primary reasons why Promises and async/await were introduced in JavaScript.

Promise

JavaScript Promises are objects that represent the eventual completion (or failure) of an asynchronous operation and its resulting value. They provide a more elegant and organized way to work with asynchronous code compared to traditional callback functions. Promises are widely used for handling asynchronous tasks like fetching data from a server, reading files, or making HTTP requests.

A Promise has three possible states:

  1. Pending: The initial state when the Promise is created, and the asynchronous operation is still ongoing.
  2. Fulfilled: The Promise is resolved, meaning the asynchronous operation completed successfully, and it now holds a value.
  3. Rejected: The Promise encountered an error during its execution, and it holds the reason for the failure.

A Promise is constructed using the Promise constructor, which takes a single function as its argument, known as the "executor." The executor function is called immediately when the Promise is created and typically contains asynchronous code.

Syntax for creating a Promise:

code)

Example of a simple Promise:

Let's create a Promise that simulates a delay and resolves with a message after the delay.

code)

Chaining Promises:

Promises can be chained together to perform multiple asynchronous operations sequentially. This is achieved using the .then() method, which allows you to attach fulfillment and rejection handlers to the Promise.

code)

Handling Rejections and Throwing Errors Manually:

To handle rejections, you can use the .catch() method at the end of the Promise chain. If any of the promises in the chain rejects, the control will jump directly to the nearest ``.catch()` block.

Additionally, you can manually throw errors inside the executor function or any then callback, and that will cause the Promise to be rejected.

code)

In this example, the performTask Promise explicitly rejects with an error after a timeout. The .catch() block will handle the rejection and display the error message.

Promises offer a cleaner and more organized way to handle asynchronous code, avoiding deeply nested callback hell. They are essential in modern JavaScript for managing asynchronous tasks effectively.

.finally()

The finally() method in JavaScript Promises is a method that gets executed no matter if the promise is fulfilled or rejected. It is usually used for performing cleanup tasks after the promise is settled, such as stopping a loading spinner, regardless of whether the operation was successful or not.

Here's the basic syntax of finally(): code)

In this example, a request is made to a URL, and the response is logged to the console. If there's an error, it's also logged to the console. No matter the outcome, finally() is called to update the loading state and log it.

Remember, the main use of finally() is to perform some task after the promise has settled, regardless of whether it was resolved or rejected.

Async/await

async/await is a syntactical feature (sugar) introduced in ECMAScript 2017 (ES8) that simplifies working with Promises, making asynchronous code look more like synchronous code. It is built on top of Promises and provides a more readable and intuitive way to handle asynchronous operations.

With async/await, you can write asynchronous code in a linear and straightforward manner without using .then() and .catch() chains. It allows you to write asynchronous code that resembles synchronous code, making it easier to understand and maintain.

How async/await works:

  1. The async keyword is used before a function declaration or expression to define an asynchronous function. An asynchronous function always returns a Promise.
  2. Inside an asynchronous function, you can use the await keyword before a Promise to pause the function's execution until the Promise is resolved. The await keyword can only be used inside an asynchronous function.
  3. When an asynchronous function encounters an await statement, it will pause execution and wait for the Promise to resolve. If the Promise rejects, an error is thrown, and you can handle it using try/catch blocks.

Example using async/await:

Let's convert the previous examples of fetching user data and posts using Promises into async/await syntax:

code)

Chaining Promises with async/await:

Now let's convert the example of fetching user data and posts using Promise chaining to async/await: code)

Running Promises in Parallel with Async/Await

Promise.all is a useful feature that allows you to handle multiple asynchronous operations concurrently and wait for all of them to complete. It takes an array of Promises as input and returns a new Promise that resolves with an array of results when all the input Promises have been fulfilled, or rejects if any of the input Promises are rejected.

Using async/await with Promise.all can make the code even more concise and readable when dealing with multiple asynchronous operations.

Example of using Promise.all with async/await:

Let's say we want to fetch user data and posts concurrently using Promise.all and async/await.

code)

In this example, the fetchUserDataAndPosts async function uses Promise.all to fetch user data and posts concurrently. We create an array of Promises, [fetchUserData(userId), fetchUserPosts(userId)], and then await the result of Promise.all. The result will be an array containing the user data and the user posts, which we can destructure into separate variables user and posts.

Both fetchUserData and fetchUserPosts will be executed simultaneously, and the function will wait until both Promises are resolved. If any of the Promises reject, the catch block will handle the error.

Using async/await with Promise.all can be quite powerful and makes it more straightforward to handle multiple asynchronous operations concurrently in your code.

Error Handling with async/await:

To handle errors in async/await functions, you can use regular try and catch blocks. When an error occurs inside an async function and it throws an error or rejects a Promise, the catch block will catch the error, and you can handle it accordingly.

code)

In this example, the performTask async function simulates an error using the delay function and then throws an error manually. The error is caught in the catch block, and the error message is displayed.

Overall, async/await simplifies asynchronous code, making it more readable and easier to reason about. It provides a great alternative to using Promises with .then() and .catch() chains.

Race, Allsettled and Any

Besides Promise.all, which we discussed earlier, there are three other Promise combinators introduced in ECMAScript 2021 (ES12) that offer different ways to handle multiple Promises: Promise.race, Promise.allSettled, and Promise.any.

  1. Promise.race: The Promise.race method takes an array of Promises as input and returns a new Promise that settles (either fulfills or rejects) as soon as any of the input Promises settles. The settled Promise will have the same fulfillment value or rejection reason as the first Promise that settles.

    This is useful when you want to race multiple asynchronous operations and take the result of the first one to complete, ignoring the rest.

    Example of using Promise.race:

    code)

In this example, we use Promise.race to race three delay Promises, and the result will be the value of the first Promise to settle (in this case, the Promise delayed by 1000 milliseconds).

  1. Promise.allSettled: The Promise.allSettled method takes an array of Promises as input and returns a new Promise that resolves with an array of objects representing the fulfillment status of each input Promise (whether they fulfill or reject). This method differs from Promise.all, as it waits for all the Promises to settle, even if some of them reject.

    Example of using Promise.allSettled: code)

    In this example, we use Promise.allSettled to fetch user data and user posts. Even though fetchUserData and fetchUserPosts return rejections when the userId is not equal to 123, Promise.allSettled will still wait for both Promises to settle and return an array of objects containing their fulfillment status.

  2. Promise.any: The Promise.any method takes an array of Promises as input and returns a new Promise that settles (fulfills) as soon as any of the input Promises fulfills. If all the input Promises reject, then Promise.any will reject with an AggregateError, which is a new error type introduced in ES12 that aggregates multiple errors.

    Example of using Promise.any:

    code)

    In this example, we use Promise.any to fetch user data and user posts. If either of the Promises fulfills (in this case, the userId is equal to 123 for both Promises), the result will be that fulfilled Promise's value. If both Promises reject, Promise.any will reject with an AggregateError, containing all the errors from the rejected Promises.

    These Promise combinators provide different ways to handle multiple Promises and offer more flexibility when dealing with asynchronous operations in JavaScript.

Asynchronous JavaScript: Under the Hood

code)

JavaScript uses the concept of event loop, call stack, task queue (also called message queue or callback queue), and microtask queue to manage synchronous and asynchronous operations. JavaScript runtime environment is essentially single-threaded, meaning that it can only perform one task at a time.

Here's how it works:

  1. Event Loop: This is a continuous cycle that checks if there are any tasks that need to be executed. It keeps on running and checking if there are tasks in the call stack or in the callback queue.

  2. Call Stack: This is where the code execution takes place. It uses a data structure called stack (execution context) where it puts tasks to be executed. If a function is called, it is put on top of the stack and executed.

  3. Task Queue (callback queue): This is where callbacks of asynchronous operations are put once the operation is complete. It's also known as the callback queue or message queue.

  4. Microtask Queue: This is another queue similar to the task queue but has a higher priority where callback related to Promises are stored. Microtasks are processed after the current execution context (call stack) is empty and before control is returned to the event loop to get more tasks.

JavaScript uses a "run to completion" model. This means that once a task starts running, it will run until it's finished and no other task can run at the same time. Once a task is finished, if there are any microtasks in the microtask queue, all of them will be processed before moving on to the next task. The process of executing tasks and microtasks repeats indefinitely as long as there are tasks to execute.

Promises, MutationObserver and queueMicrotask generate microtasks in JavaScript. When you use Promises and their methods like .then(), .catch(), .finally(), or async/await, you're adding functions to the microtask queue.

Here's an example:

code)

The output would be:

Start
End
Microtask 1

The reason is the microtask console.log('Microtask 1') is executed after the current synchronous tasks finish their execution, even though it was added to the queue before 'End' was logged.

To put it simply, the microtask queue is a queue with a higher priority than the task queue. The event loop will process all tasks in the microtask queue before moving on to the next task in the task queue. This allows JavaScript to handle asynchronous operations with more precision and control.

Share:

Accelerating Digital Success. Experience the future of web development – faster, smarter, better. Lets innovate together.

©2024 Dreit Technologies | All rights reserved

Contact

  • +38 64 577 3034
  • Serbia
  • Marka Pericina Kamenjara 11A, Ruma
  • Contact us