Async/Await in JavaScript: From Confused to Confident
Stop wrestling with promise chains. Learn how async/await works under the hood — from the event loop to parallel execution — and write async JavaScript that actually makes sense.

👋 Hey, I'm Mohd Kaif – a student documenting my journey through code. I write about what I'm learning in real-time – the wins, the struggles, and the "aha!" moments. From JavaScript and React to backend systems with Node.js, databases, DevOps, TypeScript, and AI integrations. This blog is my public learning journal: honest, evolving, and always exploring. If you're curious about any of these topics, let's learn and build together!
You know that feeling when your JavaScript code looks correct, but something's still wrong?
You wrote the fetch call. The data comes back. But somehow the console logs fire in the wrong order, your UI renders before the data arrives, and you spend an hour wondering if the entire universe is asynchronous.
Sound familiar? You might be nodding if you've ever:
Stared at a
.then().then().then()chain that was somehow still not doing what you wantedWondered why your variable is
undefinedeven though you just fetched itCopied someone's async code, changed a few things, and watched it completely fall apart
Felt like promises were a step forward, but still somehow made your head spin
The problem isn't that you don't understand JavaScript. Asynchronous code is genuinely tricky — it breaks the mental model most of us built when learning programming.
If you've ever felt lost in callback hell or confused by promise chains, this article is for you.
✅ What You'll Learn
Why async/await was introduced and what problem it actually solves
How async functions work under the hood (including the event loop)
When to use sequential vs. parallel execution — and why it matters for performance
How to handle errors cleanly without
.catch()spaghettiHow async/await compares to promises, so you can choose the right tool
Common mistakes that trip up even experienced developers — and how to avoid them
No prerequisites beyond basic JavaScript knowledge. If you've written a fetch() call before, you're ready.
Why Did Async/Await Even Need to Exist?
To appreciate async/await, you need to feel the pain it replaced.
The Callback Era 😵
In early JavaScript, asynchronous operations were handled with callbacks — functions you passed in and hoped would run at the right time.
getUser(userId, function(user) {
getOrders(user.id, function(orders) {
processOrders(orders, function(result) {
saveResult(result, function(saved) {
console.log("Done... finally");
});
});
});
});
This is what developers called callback hell — code that drifts to the right like it's trying to escape the screen. Error handling was a nightmare, and tracing bugs felt like archaeology.
Promises Made It Better — But Not Perfect
Promises flattened the pyramid. Instead of nesting, you chained:
getUser(userId)
.then(user => getOrders(user.id))
.then(orders => processOrders(orders))
.then(result => saveResult(result))
.catch(error => handleError(error));
Cleaner? Yes. But as soon as you needed to share data between steps, or add conditional logic, or handle errors differently at different stages — it got messy again.
Enter Async/Await
Now look at the same logic written with async/await:
async function handleUserOrders(userId) {
try {
const user = await getUser(userId);
const orders = await getOrders(user.id);
const result = await processOrders(orders);
await saveResult(result);
console.log("Done!");
} catch (error) {
handleError(error);
}
}
It reads top to bottom, like a story. Each step is clear. The error handling is in one place. And critically — it's still fully asynchronous. Nothing is blocked.
Key insight: Async/await is syntactic sugar over promises. It doesn't replace them — it gives you a cleaner way to write them. Under the hood, it's still promises all the way down.
How Async Functions Actually Work
An async function has one simple rule: it always returns a promise, no matter what you return from it.
async function greet() {
return "Hello, world!";
}
// This is exactly equivalent to:
function greet() {
return Promise.resolve("Hello, world!");
}
You can prove this to yourself right now:
const result = greet();
console.log(result); // Promise { 'Hello, world!' }
result.then(console.log); // "Hello, world!"
There are three things an async function can do with its return value:
| What you write | What the promise does |
|---|---|
return value |
Resolves with that value |
throw new Error(...) |
Rejects with that error |
return anotherPromise |
Adopts that promise's state |
The await Keyword: The Real Magic
await is what makes async functions powerful. You can only use it inside an async function, and here's what it does:
Calls the function/expression to its right
Waits for the resulting promise to resolve
Unwraps the resolved value and hands it back
async function loadDashboard(userId) {
const user = await fetchUser(userId); // waits, then unwraps
const posts = await fetchPosts(user.id); // waits, then unwraps
renderDashboard(user, posts);
}
The Crucial Misunderstanding: Does await Block Everything?
No. This trips up almost every developer the first time.
await pauses execution inside that one function only. The rest of your program — the event loop, other callbacks, UI interactions — keeps running normally.
Think of it like a chef who puts a dish in the oven and walks away to prep something else. The oven is "awaiting" — but the kitchen hasn't stopped.
Here's what's actually happening when loadDashboard() hits an await:
Function starts —
loadDashboard()runs synchronously until it hits the firstawaitFunction suspends —
await fetchUser()fires and the function pauses, handing control backEvent loop stays free — other callbacks, UI events, and timers continue running normally
Promise resolves —
fetchUser()finishes and the function wakes back up with its valueCycle repeats —
await fetchPosts()fires, the same handoff happens againFinal render — once every
awaitis settled,renderDashboard()runs
The key word is suspended, not blocked. The function steps aside and lets everything else keep moving — then picks up exactly where it left off.
Async Function Execution Flow (Step by Step)
Let's trace exactly what happens when you run an async function:
async function loadData() {
console.log("1. Starting");
const data = await fetchData(); // fetchData takes 2 seconds
console.log("3. Got data:", data);
}
loadData();
console.log("2. This runs immediately after calling loadData()");
Output:
1. Starting
2. This runs immediately after calling loadData()
3. Got data: [...] ← 2 seconds later
Here's the step-by-step breakdown:
Step 1: loadData() is called
└─ Starts executing synchronously
Step 2: Hits "await fetchData()"
└─ fetchData() is called → returns a Promise
└─ loadData() is suspended (not blocked)
└─ Control returns to the caller
Step 3: "This runs immediately..." executes
└─ Event loop handles other tasks
Step 4: fetchData() Promise resolves (2 sec later)
└─ loadData() is resumed
└─ "data" is assigned the resolved value
└─ Execution continues normally
Under the hood: When
awaitsuspends the function, JavaScript places the rest of it in the microtask queue. Once the awaited promise resolves, the microtask queue processes the continuation before any macro-tasks (likesetTimeoutcallbacks). This is why async/await is precise and predictable.
🛠️ Mini Exercise #1
Open your browser console and run this. Before running it, write down what order you think the numbers will print:
async function order() {
console.log("A");
await Promise.resolve();
console.log("C");
}
order();
console.log("B");
Expected output: A → B → C
If you got it right, you understand how await yields control. If not, re-read the execution flow above and run it again. This one concept is the foundation of everything else.
Error Handling: The Clean Way
One of async/await's biggest wins is making error handling feel natural.
With Promises
fetchUser(id)
.then(user => fetchOrders(user.id))
.then(orders => processOrders(orders))
.catch(error => {
// Which step failed? Hard to tell.
console.error(error);
});
With Async/Await
async function loadUserDashboard(id) {
try {
const user = await fetchUser(id);
const orders = await fetchOrders(user.id);
const result = await processOrders(orders);
return result;
} catch (error) {
console.error("Dashboard load failed:", error.message);
showErrorToUser("Couldn't load your dashboard. Try again.");
}
}
You can also catch errors at individual steps when you need different handling for different failures:
async function loadUserDashboard(id) {
let user;
try {
user = await fetchUser(id);
} catch {
throw new Error("User not found — check your credentials.");
}
try {
const orders = await fetchOrders(user.id);
return await processOrders(orders);
} catch {
return { user, orders: [], message: "Orders temporarily unavailable" };
}
}
This level of granular control is genuinely hard to achieve with chained .catch() calls.
Async/Await vs. Promises: A Real Comparison
Here's the same real-world scenario written both ways — fetching a user, their posts, and then enriching each post with author details:
With Promises
function loadBlogPage(userId) {
let currentUser;
return fetchUser(userId)
.then(user => {
currentUser = user; // need to store it for later
return fetchPosts(user.id);
})
.then(posts => {
return Promise.all(
posts.map(post =>
fetchAuthor(post.authorId).then(author => ({ ...post, author }))
)
);
})
.then(enrichedPosts => render(currentUser, enrichedPosts))
.catch(handleError);
}
Note the let currentUser workaround — a classic promise smell. You have to "leak" variables outside the chain to share them between steps.
With Async/Await
async function loadBlogPage(userId) {
try {
const user = await fetchUser(userId);
const posts = await fetchPosts(user.id);
const enrichedPosts = await Promise.all(
posts.map(async post => {
const author = await fetchAuthor(post.authorId);
return { ...post, author };
})
);
render(user, enrichedPosts);
} catch (error) {
handleError(error);
}
}
No variable leaking. No chain gymnastics. Just clear, readable steps.
| Aspect | Promises | Async/Await |
|---|---|---|
| Syntax | .then() chaining |
Linear, sequential |
| Readability | Medium | High |
| Error handling | .catch() |
try/catch |
| Shared variables | Awkward workarounds | Natural scoping |
| Debugging | Stack traces are noisy | Much cleaner |
| Parallel execution | Promise.all() |
Promise.all() with await |
The bottom line: Async/await doesn't make promises obsolete. You'll still use
Promise.all,Promise.race, andPromise.allSettled— just withawaitin front of them.
Sequential vs. Parallel: The Performance Trap
This is where a lot of developers unknowingly slow down their apps.
Sequential (when you don't need to)
// ❌ These requests have nothing to do with each other
// but this waits for each one before starting the next
async function loadProfile(userId) {
const user = await fetchUser(userId); // 300ms
const settings = await fetchSettings(userId); // 200ms
const notifications = await fetchNotifs(userId); // 150ms
// Total: ~650ms
}
Parallel (the right approach here)
// ✅ All three fire at the same time
async function loadProfile(userId) {
const [user, settings, notifications] = await Promise.all([
fetchUser(userId),
fetchSettings(userId),
fetchNotifs(userId)
]);
// Total: ~300ms (the slowest one)
}
Same result. Half the time.
Sequential: [──user──][──settings──][──notifs──] 650ms
Parallel: [──user──] 300ms
[─settings─]
[─notifs──]
When to use sequential: When operation B depends on the result of operation A.
When to use parallel: When operations are independent of each other.
🛠️ Mini Exercise #2
You're building a product page. You need to fetch:
Product details (by
productId)Reviews (by
productId)Seller info (by
sellerId, which comes from the product details)
Write the async function. Think about which fetches can run in parallel and which must be sequential.
Hint: Reviews and product details can run together. Seller info depends on product details.
✅ See a possible solution
async function loadProductPage(productId) {
// Fetch product details and reviews in parallel
const [product, reviews] = await Promise.all([
fetchProduct(productId),
fetchReviews(productId)
]);
// Seller info depends on product, so it comes after
const seller = await fetchSeller(product.sellerId);
renderPage({ product, reviews, seller });
}
Common Mistakes (And How to Actually Fix Them)
❌ Mistake 1: Using await Outside an Async Function
// This crashes with a SyntaxError
const data = await fetchData();
// ✅ Wrap it
async function run() {
const data = await fetchData();
}
// Or use a top-level async IIFE
(async () => {
const data = await fetchData();
})();
Note: Modern environments (Node.js 14.8+, modern browsers) support top-level await in ES modules — but you still need to be in a module context.
❌ Mistake 2: Forgetting Error Handling
async function saveUserData(user) {
const result = await db.save(user); // 💥 Unhandled rejection if this fails
return result;
}
// ✅ Always account for failure
async function saveUserData(user) {
try {
return await db.save(user);
} catch (error) {
logger.error("Failed to save user:", error);
throw new Error("Save failed. Please try again."); // Re-throw a clean error
}
}
❌ Mistake 3: Awaiting Inside a forEach Loop
This is one of the most common async bugs:
// ❌ forEach doesn't await — all of these fire simultaneously
// AND you lose the results
userIds.forEach(async (id) => {
await processUser(id);
});
console.log("Done?"); // Runs immediately — nothing is actually done
// ✅ Option A: Sequential with for...of
for (const id of userIds) {
await processUser(id);
}
// ✅ Option B: Parallel with Promise.all
await Promise.all(userIds.map(id => processUser(id)));
❌ Mistake 4: Mixing .then() and await Randomly
// 😬 Don't do this — it's confusing and hard to debug
async function messy() {
const data = await fetchData().then(d => transform(d));
return data;
}
// ✅ Pick one style and stick to it
async function clean() {
const raw = await fetchData();
return transform(raw);
}
🛠️ Mini Exercise #3
Spot the bug in this code:
async function sendNotifications(userIds) {
userIds.forEach(async (id) => {
const user = await fetchUser(id);
await sendEmail(user.email, "Hello!");
});
console.log("All notifications sent!");
}
Questions:
When does
"All notifications sent!"actually log?What happens if one email fails?
How would you fix it?
(Try answering before scrolling to the Common Mistakes section above for hints.)
Under the Hood: The Event Loop and Microtask Queue
When you await a promise, JavaScript doesn't freeze. Here's precisely what happens:
Call Stack Microtask Queue Macro-task Queue
───────── ───────── ────────
loadData() runs → (empty) setTimeout callbacks
hits await → continuation added setInterval callbacks
loadData() pops → ... I/O callbacks
other code runs → ...
(stack is empty) → continuation runs ✅
The key insight: microtasks (promise continuations) always run before the next macro-task. This is why async/await is so predictable — your continuation runs as soon as possible after the promise resolves, before any timers or I/O callbacks.
This also explains the output from Exercise #1. await Promise.resolve() schedules a microtask. The current synchronous code (console.log("B")) finishes first, then the microtask queue processes console.log("C").
Real-World Patterns You'll Use Every Day
API Calls with Fetch
async function getWeather(city) {
const response = await fetch(`/api/weather?city=${city}`);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: Failed to fetch weather`);
}
return response.json(); // .json() also returns a promise — await handles it
}
Database Queries (Node.js)
async function getUserWithPosts(userId) {
const [user, posts] = await Promise.all([
db.query("SELECT * FROM users WHERE id = ?", [userId]),
db.query("SELECT * FROM posts WHERE user_id = ?", [userId])
]);
return { ...user, posts };
}
File Operations (Node.js)
import { readFile, writeFile } from "fs/promises";
async function transformConfig(inputPath, outputPath) {
const raw = await readFile(inputPath, "utf-8");
const config = JSON.parse(raw);
config.updatedAt = new Date().toISOString();
await writeFile(outputPath, JSON.stringify(config, null, 2));
console.log("Config updated successfully.");
}
Retry Logic
async function fetchWithRetry(url, retries = 3) {
for (let attempt = 1; attempt <= retries; attempt++) {
try {
const response = await fetch(url);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return await response.json();
} catch (error) {
if (attempt === retries) throw error;
console.warn(`Attempt ${attempt} failed. Retrying...`);
await new Promise(res => setTimeout(res, 1000 * attempt)); // exponential backoff
}
}
}
When Async/Await Isn't the Right Tool
Async/await is powerful, but it's not universal. Skip it (or use it carefully) when:
Working with streams — Node.js streams are event-based. Use the
stream/promisesAPI orfor await...ofinstead.Event emitters —
EventEmitterpatterns don't map cleanly to promises. You'd need to wrap them manually.Fine-grained promise control — If you need
Promise.race(),Promise.any(), orPromise.allSettled(), you'll use those directly — though still withawaitin front.Performance-critical loops with large arrays —
Promise.all()on 10,000 items simultaneously can overwhelm resources. Consider batching.
What to Learn Next
Now that async/await feels solid, here's where to go from here:
Promise.allSettled()andPromise.any()— These handle scenarios where you want results even if some promises fail, or want the first to succeed. Great for resilient UIs.Async Iterators and
for await...of— If you're working with paginated APIs, file streams, or WebSockets, this is how async/await scales to continuous data.AbortController and Cancellable Requests — Learn how to cancel in-flight fetch requests when a component unmounts or a user navigates away — a common source of bugs in React apps.
Error boundaries and global async error handling —
window.onunhandledrejectionin the browser,process.on('unhandledRejection')in Node.js. Production apps need this.
Wrapping Up
Async/await didn't change how JavaScript handles asynchronous work — the event loop, the microtask queue, the promise machinery — all of that still runs exactly the same. What it changed is how you interact with it.
Instead of building chains of .then() calls and managing error propagation across them, you write code that tells a clear story: fetch this, then do that, catch anything that goes wrong.
For the teams maintaining code six months from now, that clarity isn't just aesthetic — it's the difference between a bug that takes five minutes to find and one that takes five hours.
You now have the mental model, the patterns, and the pitfalls. Put them to use.
💬 Got Questions?
Drop a comment below! I'd love to hear what you're building and where async/await has given you trouble — or saved the day.
Here are topics I'm planning to cover next:
Promise.allvsPromise.allSettledvsPromise.any: Which combinator to reach for in different real-world scenariosAsync Iterators and
for await...of: How to handle streaming and paginated data elegantlyCancelling Async Operations with AbortController: Preventing memory leaks and race conditions in React apps
Node.js Async Patterns in Production: Error handling, timeouts, and retry strategies for backend services
Found this helpful? Share it with someone who's fighting their way through promise chains right now — you might just save them an afternoon.
Happy coding! 🚀




