Async/Await in JavaScript Explained
Learn how async/await works under the hood in JavaScript, how it relates to Promises, and how to handle errors, parallelism, and common pitfalls correctly.
Introduction
Asynchronous code used to mean callback pyramids or long chains of .then(). Then async/await arrived and made asynchronous JavaScript read almost like synchronous code. But that simplicity hides real behavior you need to understand: async/await is not magic, it is a thin, readable layer over Promises, and knowing what happens underneath will save you from a long list of subtle bugs.
This guide explains how async/await actually works, how it interacts with the event loop, and how to avoid the mistakes that even experienced developers make with it.
Promises: A Quick Refresher
A Promise represents a value that may not be available yet. It can be pending, fulfilled with a value, or rejected with an error. Async/await is built directly on top of this model.
function fetchUser(id) {
return new Promise((resolve, reject) => {
setTimeout(() => {
if (id > 0) resolve({ id, name: "Ada Lovelace" });
else reject(new Error("Invalid id"));
}, 500);
});
}
How async and await Work
Marking a function async guarantees that it always returns a Promise, even if you write a plain return statement inside it. If the function returns a value, that value becomes the resolved value of the Promise; if it throws, the Promise rejects with that error.
async function getUserName(id) {
const user = await fetchUser(id);
return user.name;
}
getUserName(1).then((name) => console.log(name)); // "Ada Lovelace"
The await keyword can only be used inside an async function (or at the top level of a module). When the engine hits await somePromise, it pauses execution of that async function — and only that function — until the Promise settles. Crucially, it does not block the entire program. Control returns to the event loop immediately, so other code keeps running while the await is "waiting."
Error Handling with try/catch
Because a rejected awaited Promise throws inside the async function, you can catch it exactly like a synchronous error:
async function getUserNameSafely(id) {
try {
const user = await fetchUser(id);
return user.name;
} catch (error) {
console.error("Failed to fetch user:", error.message);
return null;
}
}
This is a major readability win over chaining .then().catch(), especially once you have several dependent asynchronous steps.
Running Operations in Parallel
A very common mistake is awaiting independent operations one after another, which serializes work that could run concurrently:
// Slower: each await waits for the previous one to finish
const user = await fetchUser(1);
const posts = await fetchPosts(1);
// Faster: both requests start immediately
const [userFast, postsFast] = await Promise.all([fetchUser(1), fetchPosts(1)]);
Promise.all starts every Promise in the array right away and resolves once all of them are done, or rejects as soon as any one of them fails. If you need to know the outcome of each Promise regardless of failures, Promise.allSettled is the better tool, since it never short-circuits on rejection.
Sequential vs Concurrent Loops
Using await inside a for...of loop processes items one at a time, which is correct when each step depends on the previous result, but wasteful when the steps are independent:
// Sequential — one request at a time
for (const id of userIds) {
const user = await fetchUser(id);
console.log(user.name);
}
// Concurrent — all requests fired together
const users = await Promise.all(userIds.map((id) => fetchUser(id)));
users.forEach((u) => console.log(u.name));
Be careful with concurrency limits, though — firing hundreds of requests at once against a rate-limited API can cause failures. In those cases, batch requests or use a concurrency-limiting utility.
Top-Level Await
Modern JavaScript modules support await outside of any function, at the top level of a module. This is convenient for initialization code but should be used sparingly, since it can delay the loading of any module that imports it.
// inside an ES module
const config = await loadConfig();
export default config;
Best Practices
- Always pair
awaitwith try/catch or a.catch()somewhere in the call chain — never leave a rejected Promise unhandled. - Use
Promise.allorPromise.allSettledwhen operations do not depend on each other. - Avoid mixing
.then()chains andawaitin the same function; pick one style for clarity. - Return early from async functions when a guard condition fails, rather than nesting deeply.
- Remember that an
asyncfunction always returns a Promise, so calling it withoutawaitgives you a Promise, not the resolved value.
Common Mistakes to Avoid
- Forgetting the
awaitkeyword, which silently turns your result into a pending Promise instead of the value you expected. - Awaiting inside a loop when the operations could run in parallel, hurting performance for no reason.
- Using
asyncon array callbacks likeforEach, which does not wait for the returned Promises and can cause code after the loop to run before the async work finishes. - Swallowing errors by catching them and doing nothing, which hides real bugs from your logs and users.
Cancelling Async Work
Promises themselves cannot be cancelled once started — there is no built-in .cancel() method. When you need to abandon an in-flight operation (a user navigates away, or a newer request supersedes an older one), the standard tool is AbortController:
async function fetchWithTimeout(url, timeoutMs) {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetch(url, { signal: controller.signal });
clearTimeout(timeoutId);
return await response.json();
} catch (error) {
if (error.name === "AbortError") {
throw new Error("Request timed out");
}
throw error;
}
}
Calling controller.abort() causes the fetch call to reject with an AbortError, letting you distinguish "the request failed" from "we deliberately gave up on it." This pattern is especially useful in UI code: if a user types a new search query before the previous one resolves, aborting the stale request avoids a race where an old, slower response overwrites newer results on screen. Many libraries built on top of fetch, including most HTTP clients, accept an AbortSignal for exactly this reason, so it is worth learning once and reusing everywhere you have overlapping asynchronous requests.
Top-Level Await and Async Iteration
Modern JavaScript modules support await at the top level of a file, outside of any async function. This is convenient for scripts and modules that need to perform setup work — like loading configuration or connecting to a database — before the rest of the module runs:
// config.mjs
const response = await fetch("https://api.example.com/config");
export const config = await response.json();
Any module that imports config.mjs will automatically wait for that top-level await to resolve before its own code runs, which is useful for one-time async initialization but can slow down an entire dependency graph if overused, since each awaited module blocks the modules that import it. Async/await also pairs naturally with for await...of for consuming async iterables, such as reading a stream of data chunk by chunk:
async function readAll(asyncIterable) {
const chunks = [];
for await (const chunk of asyncIterable) {
chunks.push(chunk);
}
return chunks;
}
This loop automatically awaits each value produced by the async iterable before moving to the next iteration, which is exactly how you consume Node.js readable streams, paginated API results wrapped in an async generator, or any other source that produces values over time rather than all at once.
Conclusion
Async/await did not change what JavaScript can do asynchronously — it changed how enjoyable that code is to write and review. Once you understand that it is Promises with better syntax, and that the event loop is still running underneath, you can use it confidently: pausing where you need sequencing, parallelizing where you do not, and always handling the error path.
Article FAQ
References
Comments
Comments are coming soon. Meanwhile, share your feedback via our contact page.
Related Articles
View allThe JavaScript Event Loop Explained
Understand how the JavaScript event loop works, including the call stack, task queue, and microtask queue, with clear diagrams-in-text and runnable examples.
JavaScript Array Methods Deep Dive
A practical deep dive into the JavaScript array methods you use every day, including map, filter, reduce, find, and sort, with real examples and performance...
Closures in JavaScript: A Complete Guide
A complete guide to JavaScript closures explaining lexical scope, the scope chain, and how closures power data privacy, memoization, and function factories.
Top JavaScript Interview Questions and Answers
A curated list of common JavaScript interview questions with clear explanations, covering closures, hoisting, the event loop, prototypes, and equality compar...