When I look at JavaScript async/await I am reminded of how nice it would have been had I understood its power sooner. There are so many people who have had to waste hours untangling through then blocks or debugging a promise chain that swallowed their error silently. JavaScript async/await was made available in ES2017 to eliminate those types of problems completely. While async/await does not replace promises. It sits atop of them like a comfortable chair on a strong engine. Once you understand how it really works; your asynchronous code will be much easier to read and to maintain.
The best way to think about async/await is similar to ordering food online. You submit the order (you create a promise) and instead of standing next to your door watching for the doorbell to ring you can go relax, watch TV, fold your laundry. You are not stopped. The minute the doorbell rings you stop whatever you were doing, pick up the food, and continue with your television program. The period of time that the doorbell rang is what await does. It stops the function but does not stop the whole application.
Let’s break down the five key elements that will enable you to begin writing basic async/await and eventually be able to use it confidently in your coding.
Core Syntax of JavaScript Async/Await
While the core syntax is easy to learn. It is deceptively simple. In order to utilize async/await you add the word “async” prior to declaring a function. One single keyword changes everything. All async functions will always return a promise. No matter what you return from within an async function; whether it is a string or a number, it automatically gets wrapped in a resolved promise. Therefore, you can apply .then and .catch against the results of an async function in the same manner as any other promise.
Within an async function lies the true power of async/await: the await keyword. You add await to any expression that returns a promise. The JavaScript Engine recognizes that await is used and literally pauses the execution of that specific function. It performs other tasks such as responding to click events or processing additional scripts while it waits for that promise to settle. After the promise settles, it unpacks the settled value and resumes executing right after the point it was paused.
You should visualize this process in the following manner. Await is not some sort of magical incantation that converts asynchronous code into synchronous code. It merely provides a more readable alternative to using .then. Here is a comparison of these two methods:
const user = await fetchUserData(); // Exactly equivalent to the following line...
fetchUserData().then(user => { /*...*/ });
The difference here is that the first version reads like a normal story from top to bottom. The second example requires you to conceptually stack the logic. The improved readability is why async/await has become the preferred method for managing asynchronous functionality in today’s codebases.
Try/Catch vs .Catch Method Error Management
Here is where JavaScript async/await stands head and shoulders above native promises. With classic promise chains, there are generally two ways that you can manage error conditions. You can either attach a .catch block at the end of the chain or pass a second callback function as part of the .then method. Although both patterns can work effectively, they can quickly become confusing and difficult to manage when you require multiple levels of error management in your chain.
However, with async functions, error management collapses back into what should be a familiar pattern for synchronous error management – try/catch blocks. You surround your awaited promises with try blocks. If any of those promises fail, execution immediately leaps to your catch block. This is extremely intuitive since you have experience working with try/catch from synchronous error management.
Suppose that you needed to fetch user information followed by updating a profile. If either step failed, you wanted to display a generic error message. You would enclose both await calls in the same try block. Since the first call failed, the second will never execute, and you will find yourself safely in the catch block. This is better than attaching a single .catch block that may not properly identify which error occurred – whether it was due to networking issues or validation issues.
Although top level await is very powerful; it does introduce one trade-off related to how modules are processed. Any module that utilizes top-level await will cause all subsequent imports of said module to be delayed until the awaited promise resolves. This delay is precisely what you want when initializing any necessary initialization data, however; it is something to keep in mind when designing larger systems. Top level await is not intended as a form of lazy loading, but rather as a cleaner method of asynchronously establishing initialization logic.
Asynchronous Task Execution in Parallel
One misconception regarding JavaScript async/await is that it causes every operation to occur sequentially. Many developers write code such as this when they need to fetch three independent pieces of data:
const users = await fetchUsers();
const posts = await fetchPosts();
const comments = await fetchComments();
While this works; it is incredibly inefficient. Due to how await freezes execution until the previous operation completes; none of the second or third operations will begin execution until completion of the first one. If each operation took approximately 200 milliseconds, your system will spend around 600 milliseconds waiting for data that has no dependency on any other piece of data.
The proper paradigm involves firing off all of the promises independently (without awaiting each one individually) and then awaiting the overall outcome. You accomplish this via Promise.all along side async/await. Instead of awaiting each individual call; you assign the unresolved promises to separate variables:
const usersPromise = fetchUsers();
const postsPromise = fetchPosts();
const commentsPromise = fetchComments();
Then you can perform one await on Promise.all:
const [users, posts, comments] = await Promise.all([usersPromise, postsPromise, commentsPromise]);
Now the initial requests execute nearly concurrently. Your total wait time decreases significantly; roughly equal to the length of time required by your longest-running task (perhaps 200 milliseconds versus 600).
Pattern utilization such as this is crucial for optimizing performance and demonstrates how well async/await and Promise.all combine together.
Awaiting at the Top Level of Modules
For years; await could only be used within functions declared as “async”. You could not simply declare “await” at the beginning of an arbitrary JavaScript file. This greatly hindered development utilizing modern front-end frameworks or Node.js scripts in which you simply desired to initialize some configuration or retrieve some data prior to continuing with additional parts of your module.
ES2022 has removed this limitation via introduction of top level await. If your script loads as a module (via type=”module” attribute within script tags or via .mjs files in Node), you can now invoke await directly at the very top of any file containing your module. Therefore, you can execute actions such as dynamically importing a fallback module based upon whether an initial import succeeds/fails, etc., or wait for a configuration retrieval to complete prior to initializing constants.
While top level await is incredibly powerful; it comes with one trade-off in terms of module processing sequence: whenever a module utilizes top level await; subsequent imports of that module will be held until the awaited promise resolves. This blocking behavior is exactly what you desire for retrieving essential initialization data, but it must be taken into consideration during the design phase for larger applications. It is not meant as a replacement for lazy loading; but rather as a more streamlined approach to handling asynchronous initialization logic.
Most Commonly Encountered Mistakes & Anti Patterns
Just like anything else related to programmingm, there are also several common mistakes that developers may encounter while attempting to leverage JavaScript async/await.
Firstly: The Forgotten Await.
If you call an async function from within another async function; but fail to include “await” before calling said function; you do not obtain the resolved value from said function call. Instead; you obtain a pending promise object. Upon attempting to reference properties on said pending promise object assuming it will contain your actual data – you may observe values indicating “undefined”, etc., resulting in unexpected behaviors in your application. ESLint-type linter tools are quite effective at detecting this particular issue.
Secondly: Misuse of Loops.
Utilizing await inside a for loop (e.g., iterating over an array); is perfectly acceptable and processes iterations sequentially (i.e., dependent on results from previous iterations). However; if you attempt to utilize await within a callback passed to .forEach or .map – it behaves differently than expected. The callback function itself is not async; therefore either await results in an error being thrown or, if you specify async on the callback – there is no guarantee that external functions (calling said callback) will wait for all awaited promises to resolve prior to proceeding with execution.
For instance, if you utilized .forEach or .map – regardless of whether any awaited promises were resolved; it will proceed as though all callbacks completed successfully (since they did technically complete; albeit prematurely). To process iterations in parallel; employ Promise.all with .map (if applicable). To iterate sequentially; remain consistent with standard for-of loop.
Thirdly: Returning Values From A Catch Block Without Re-Throwing Errors.
If you handled an exception within a catch and returned a default value – callers of your original async function will receive a successful promise with said default value. While this may represent an appropriate fallback mechanism (for example, providing default values for missing configuration items); if you wish for exceptions to propagate upward through callers; you must include throw statements inside your catch blocks
Conclusion
Understanding these five concepts gives you the confidence to refactor nested callbacks and lengthy promise chains into clean, maintainable logic. For a more exhaustive look at the specification and edge cases, the MDN documentation on async functions remains the definitive resource. And for practical examples of these patterns in real-world applications, the JavaScript.info guide on async/await offers excellent interactive exercises to cement your understanding.
New to HTML? Start Here: HTML Tutorial for Beginners: Your Complete Introduction to HTML Basics
New to CSS? Start Here: CSS Introduction: Master 5 Core Concepts Easily
New to JavaScript? JavaScript Introduction: 5 Proven Steps to Learn JS
[INSERT_ELEMENTOR id=”122″]


