In this articleAsync The call stack Event Loop Callbacks ⇢ Callback Hell Promises Asynchronous functions Bundling async/await The pros of async/aw

Async in JS: How does it work | Stack Diary

submited by
Style Pass
2023-03-19 16:00:05

In this articleAsync The call stack Event Loop Callbacks ⇢ Callback Hell Promises Asynchronous functions Bundling async/await The pros of async/await Summary In JavaScript, code execution is single-threaded, which means that only one thing can happen at a time. The JavaScript engine executes code in a sequential and synchronized manner, one line at a time. This means that if there is a time-consuming task in the code, such as an API call or a long loop, the execution of the entire code will be blocked until the task is completed. During this time, the application will not be able to respond to any user input or execute any other code. The problem with synchronous code Synchronous code is easy to understand and follow, as it executes exactly as written, one line after the other. console.log('one') console.log('two') console.log('three') // Output: one two three However, this simplicity comes with a disadvantage when dealing with time-consuming operations, such as delays or network requests, as the entire execution is blocked until the operation is completed. We can further illustrate this using the delay() function: function printDelay() { console.log('Phew!') } delay(5000) printDelay() In the example above, the code requires a delay of 5 seconds before printing a greeting message to the console. However, the delay function is synchronous, which means that the execution of the entire code is blocked for 5 seconds until the delay function is completed. During this time, the JavaScript engine cannot execute any other code, making the application unresponsive. Therefore, to avoid blocking the execution of code, developers use asynchronous programming techniques such as callbacks, promises, and async/await to handle long-running tasks in a non-blocking manner. Async Asynchronous code, on the other hand, is executed in a non-sequential manner, meaning that the execution of the next line does not wait for the completion of the previous line. Instead, it continues to execute the remaining code while performing other tasks in the background. Here's an example of asynchronous code that uses the same setTimeout function to delay the execution of the code for two seconds: console.log('Start'); setTimeout(() => console.log('Inside Timeout'), 2000); console.log('End'); In this example, the setTimeout function is called with a delay of two seconds, but the execution of the code continues without waiting for it. Therefore, the 'End' message is printed immediately after the 'Start' message, and the 'Inside Timeout' message is printed after two seconds. Asynchronous code is useful when dealing with time-consuming tasks, such as network requests, file operations, or user interactions. By executing these tasks asynchronously, the rest of the code can continue to execute without being blocked, improving the overall performance and responsiveness of the application. The call stack In JavaScript, the call stack is a mechanism used by the interpreter to keep track of the current execution context during code execution. It is essentially a data structure that stores the execution context of a program in a stack-like manner. Whenever a function is called, the interpreter pushes the function call onto the top of the call stack, along with its associated arguments and variables. The interpreter then executes the function, and when it finishes executing, it pops the function call off the top of the call stack and continues executing the code from where it left off. This process continues for each function call in the program, with the call stack growing and shrinking as functions are called and returned. For example, consider the following code: function foo() { function bar() { // function 3 console.log('Hello!') } // function 2 bar() } // function 1 foo() In this code, there are three nested functions, foo, bar, and a console.log function that logs the message 'Hello!'. The foo function calls the bar function, which in turn calls the console.log function. When the code is executed, the foo function is called first, and the interpreter pushes the foo function call onto the top of the call stack. Within the foo function, the bar function is called, and the interpreter pushes the bar function call onto the top of the call stack, above the foo function call. When the bar function is called, it executes the console.log function, which logs the message 'Hello!' to the console. Once the function is executed, the interpreter pops the function off the top of the call stack and continues executing the bar function. After the bar function finishes executing, the interpreter pops it off the call stack, and control returns to the foo function, which finishes executing and is then popped off the call stack as well. Therefore, the call stack would look something like this during execution: | | | console.log() | | bar() | | foo() | |______________________| After executing the entire block, the stack will become empty. The entire chain of function calls is stored on the call stack in synchronous code. When a function calls itself repeatedly without any condition to stop, it is called recursion without a base case. This can cause a stack overflow, as each recursive call adds a new function call to the top of the stack, and if the recursion continues indefinitely, the stack will eventually run out of memory, and the program will crash. Let's see how the call stack works with asynchronous code: function main() { setTimeout(function welcome() { console.log('Welcome!') }, 3000) console.log('Goodbye!') } main() Calling the main() function, the call stack is: main(); Calling the setTimeout() function, the call stack is: setTimeout(); main(); setTimeout has finished, it exits the stack: main(); Calling console.log('Goodbye!'): console.log('Goodbye!'); main(); The task is complete, exiting the stack: main(); The main() call is also finished, and the stack becomes empty. After 3 seconds, the welcome() function is called, and it goes on the stack: welcome(); This will call console.log('Welcome!'): console.log('Welcome!'); welcome(); After it is done, it leaves the stack. welcome(); After executing the entire block, the stack becomes empty again. One thing you might not have noticed right away is that setTimeout() was terminated immediately, even though the callback function wasn't yet executed, it wasn't even called! This has to do with a mechanism known as the event loop, so let's move on to that! Event Loop In JavaScript, setTimeout() is a function that allows developers to schedule a callback function to be executed after a specified delay. However, setTimeout() is not actually part of the JavaScript language itself. It is a Web API, which means it is a functionality provided by the browser environment rather than the JavaScript engine. Web APIs are additional features provided by the browser environment that allow JavaScript to interact with the browser and its features, such as timers, intervals, and event handlers. When we use setTimeout(), it interacts with the Web API to schedule the callback function to be executed after the specified delay. The event loop is a mechanism that is responsible for executing code, collecting and handling events, and executing subtasks from the queue. When an asynchronous event is triggered, such as a setTimeout() callback, it is added to the event queue. The event loop continuously checks the event queue for new events, and when it finds an event, it dequeues it and executes the associated callback function. Therefore, when we use setTimeout(), it is not executed immediately, but instead scheduled to be executed in the future by the Web API. The event loop is responsible for managing the execution of the callback function by dequeuing it from the event queue and executing it when it is the appropriate time. It is the event loop that is responsible for setTimeout() missing from the stack in the last example. Now, let's take the previous example we examined and draw a clear picture of what is happening: function main() { setTimeout(function welcome() { console.log('Welcome!') }, 3000) console.log('Goodbye!') } main() To best illustrate how this works, let's include not only the stack but also the Web API and the task queue that the Web API uses to store what needs to be executed. Calling: main() StackWeb APITask Queuemain() Web API and task queue are empty for now. Calling: setTimeout() StackWeb APITask QueuesetTimeout() main() When setTimeout() disappears from the stack, it enters Web API visibility, where the interpreter understands that there is a welcome() function inside it to be executed after 3 seconds: StackWeb APITask Queuemain()setTimeout(welcome) This is followed by a console.log('Goodbye!') call. The setTimeout(welcome) function remains in the Web API. It will be there until 3 seconds have elapsed: StackWeb APITask Queueconsole.log('Goodbye!') main()setTimeout(welcome) The console.log() has been processed, the main() call ends: StackWeb APITask Queuemain()setTimeout(welcome) The main() call has ended, so the stack is emptied, but because 3 seconds haven't passed yet, the setTimeout(welcome) function is still inside the Web API: StackWeb APITask QueuesetTimeout(welcome) Finally, 3 seconds have passed - the welcome() function moves to the task queue: StackWeb APITask Queuewelcome() The event loop now moves the welcome() function from the task list to the call stack: StackWeb APITask Queuewelcome() This then calls console.log('Welcome!'): StackWeb APITask Queueconsole.log('Welcome!') welcome() The stack is now empty. In JavaScript, the call stack and the task queue are two key components of the event loop. The call stack is a last in, first out (LIFO) data structure that tracks the current position of code execution. The task queue, also known as the event queue, is a first in, first out (FIFO) data structure that holds callback functions waiting to be executed. The call stack and the task queue are named stack and queue because they work on LIFO and FIFO principles, respectively. This means that the most recently added function to the stack is the first to be executed (LIFO), while the first added function to the queue is the first to be executed (FIFO). This is Loupe, a tool built by Philip Roberts. It is designed to help developers understand how JavaScript's call stack/event loop/callback queue interact with each other. Callbacks In JavaScript, a callback function is a function that is passed as an argument to another function and is executed when some event occurs. The event can be anything, such as a timer expiration, completion of a network request, or a user action like clicking a button. In the context of the setTimeout() example, the callback function is the welcome() function, which is executed after a 3-second delay. When the timer expires, the setTimeout() function invokes the welcome() function as a callback. Initially, callbacks were the only way to handle asynchronous code in JavaScript, and many Node.js APIs were designed specifically to work with callbacks. The mental model of callbacks is simple: "execute this function when this event happens." However, callbacks can lead to a phenomenon called "callback hell," which occurs when multiple nested callbacks are used, making the code difficult to read and maintain. This can result in code that is hard to debug and prone to errors. ⇢ Callback Hell Suppose we have a number of asynchronous tasks that depend on each other: that is, the first task starts a second task when completed, the second task starts a third, etc. function task1(callback) { setTimeout(() => { /* .. */ callback(); }, 1000); } function task2(callback) { setTimeout(() => { /* .. */ callback(); }, 1000); } function task3(callback) { setTimeout(() => { /* .. */ callback(); }, 1000); } task1(() => { task2(() => { task3(() => { console.log("All tasks completed"); }); }); }); In this example, we have three asynchronous tasks (task1, task2, and task3) that depend on each other. Each task takes a callback function as an argument, which is executed when the task is completed. As you can see, the code becomes deeply nested and harder to read as we chain these tasks together. The answer to this problem? Promises. Promises A Promise is a wrapper object for asynchronous code that represents the eventual completion or failure of a single asynchronous operation. A Promise has three states: pending - The initial state, neither fulfilled nor rejected. fulfilled - The operation completed successfully, resulting in a value. rejected - The operation failed, resulting in an error. In terms of the event loop, a Promise is similar to a callback. The function to be executed (either resolve or reject) is in the Web API environment, and when the event occurs, it goes to the task queue, from where it goes to the call stack for execution. Promises introduce a division between macro-tasks and micro-tasks. A Promise's then method is a micro-task, which means it is executed before any macro-tasks such as setTimeout. Micro-tasks are added to the micro-task queue, while macro-tasks are added to the macro-task queue. Here's an example that demonstrates the use of both resolve and reject in Promises. In this example, we'll simulate the process of fetching user data based on a user ID. If the user is found, we'll resolve the Promise, otherwise, we'll reject it. function getUserData(userId) { return new Promise((resolve, reject) => { setTimeout(() => { const users = { 1: { id: 1, name: "Alice" }, 2: { id: 2, name: "Bob" }, }; const user = users[userId]; if (user) { resolve(user); } else { reject(new Error("User not found")); } }, 1000); }); } getUserData(1) .then((userData) => { console.log("User data:", userData); }) .catch((error) => { console.error("Error:", error); }); getUserData(3) .then((userData) => { console.log("User data:", userData); }) .catch((error) => { console.error("Error:", error); }); In this example, getUserData returns a Promise that simulates an asynchronous operation to fetch user data. After a 1-second delay, the Promise is either fulfilled with the user data (using resolve()) or rejected with an error message (using reject()). We call getUserData() with two different user IDs: 1 and 3. For user ID 1, the Promise is resolved and the user data is logged. For user ID 3, the Promise is rejected and the error message is logged. We use the then() method to handle the fulfilled Promise and the catch() method to handle the rejected Promise. Here's a comparison between the callback-based code and the Promise-based code: Callback-based code: task1(() => { task2(() => { task3(() => { console.log("All tasks completed"); }); }); }); Promise-based code: task1() .then(() => { return task2(); }) .then(() => { return task3(); }) .then(() => { console.log("All tasks completed"); }); As you can see, the Promise-based code is easier to read and understand. It also makes it simpler to add, remove, or modify tasks without having to change the structure of the code significantly. This leads to better maintainability and a reduced risk of introducing bugs. Despite their advantages, Promises do have a few drawbacks: Code conciseness - Although Promises improve code readability compared to callbacks, they can still result in more verbose code than desired, especially when dealing with multiple chained operations. Debugging limitations - When using arrow functions and chaining Promises, you might face difficulties in setting breakpoints for debugging since there is no function body. To overcome this limitation, you would need to expose the function, which makes the code less concise. Error stack - When an error occurs within a Promise chain, the error stack may contain several then() calls, making it harder to pinpoint the exact location of the error. Nested conditions - Handling complex conditional logic within Promises can lead to nested structures, increasing the amount of code and reducing readability. To address these issues, JavaScript introduced async/await, a more concise and readable syntax for working with Promises. With async/await, you can write asynchronous code that looks and behaves like synchronous code, making it easier to read, debug, and maintain. Asynchronous functions In short, asynchronous functions are functions that return promises. An asynchronous function is marked with a special keyword async: async function request() {} const req = async () => {} class MainClass { async request() {} } They always return a Promise. Even if we didn't explicitly specify it, as in the examples above, they would still return a Promise when called. async function request() {} // Works: request().then(() => {}) However, asynchronous functions can be handled without then(). Bundling async/await Within asynchronous functions, you can call other asynchronous functions without using then() or callbacks, but with the help of the await keyword. async function loadUsers() { const response = await fetch('/api/users/') const data = await response.json() return data } In the example above, we use the fetch() method inside the loadUsers() function. We call all asynchronous functions inside with await - that way, the Promise function returns are automatically expanded, and we get the value that was inside the Promise. The pros of async/await Async/await provides several benefits over using Promises with then() chains when working with asynchronous code: Cleaner and shorter code - Async/await helps you write cleaner and shorter code by eliminating the need for chaining then() methods. It flattens the structure, making it more readable and resembling synchronous code. Improved handling of conditions and nested constructs - With async/await, it becomes easier to work with conditional statements and nested constructs, as you can use familiar constructs like if, else, and loops in conjunction with await. This improves the readability of the code and simplifies its structure. Familiar error handling with try-catch - Async/await allows you to handle errors using try-catch blocks, similar to how you would handle errors in synchronous code. This brings consistency in error handling and makes it easier to reason about the flow of the code when an error occurs. Here's an example demonstrating the benefits of async/await: async function fetchData(id) { return new Promise((resolve, reject) => { setTimeout(() => { const data = { 1: "Success", 2: "Error", }; if (data[id] === "Success") { resolve(data[id]); } else { reject(new Error("Fetch error")); } }, 1000); }); } async function main() { try { const id = 1; const result = await fetchData(id); if (result === "Success") { console.log("Data fetched successfully"); } else { console.log("Data fetch failed"); } } catch (error) { console.error("Error:", error); } } main(); In this example, we have an async function fetchData() that simulates an asynchronous operation. The main() function uses async/await to call fetchData(). We use a try-catch block for error handling and a simple if statement to check the result of the data fetch. The code structure is clear, flat, and easy to read, showcasing the benefits of async/await over Promise chaining. Summary In conclusion, asynchronous programming is an essential aspect of JavaScript, as it enables handling tasks such as network requests, file I/O, and timers without blocking the main thread. Throughout the years, various techniques have been introduced to manage asynchronous code in JavaScript, including callbacks, Promises, and async/await. Callbacks were the initial approach, but they led to issues like callback hell, where deeply nested and hard-to-read code structures emerged. To address these problems, Promises were introduced, providing a more manageable way to work with asynchronous operations. Promises led to cleaner, more readable code and improved error handling. However, they still had some drawbacks, such as verbosity and difficulty in handling complex nested conditions. To further simplify asynchronous code, JavaScript introduced async/await, a syntax that allows writing asynchronous code resembling synchronous code. This approach offers several benefits, including cleaner and shorter code, better handling of conditions and nested constructs, and familiar error handling with try-catch blocks. By understanding the evolution of asynchronous programming in JavaScript and how each technique works, you can learn to write more efficient, maintainable, and readable code. Embracing async/await can significantly improve the overall experience of working with asynchronous operations in JavaScript applications.

In JavaScript, code execution is single-threaded, which means that only one thing can happen at a time. The JavaScript engine executes code in a sequential and synchronized manner, one line at a time.

Leave a Comment