Promises and async/await: How JavaScript Handles Waiting Gracefully
In the last post, we built a complete mental model of the JavaScript engine — the call stack, the event loop, and the two queues. We saw that Promises go into the microtask queue and always run before macrotasks. But we didn't talk about what a Promise actually is, or how async/await fits into the picture.
That's what this post is about.
📝 Note: This post was written with the assistance of AI. The content reflects my personal learning and understanding and should not be taken as professional advice. Please do your own research and due diligence before acting on anything written here.
📦 What Is a Promise?
A Promise is JavaScript's way of saying:
"I don't have the answer right now — but I promise I'll get back to you when I do."
Think of it like ordering food at a restaurant. The waiter takes your order and gives you a buzzer. That buzzer is the Promise. It doesn't have your food yet — but it represents the guarantee that food is coming. You don't have to stand at the counter waiting. You go sit down, and when the food is ready, the buzzer goes off.
In code, a Promise looks like this:
// Creating a Promise
const myPromise = new Promise((resolve, reject) => {
// Imagine this is fetching data from a server
const success = true;
if (success) {
resolve("Here is your data!"); // food is ready — buzzer goes off
} else {
reject("Something went wrong."); // kitchen ran out — buzzer signals an error
}
});
🚦 The Three States of a Promise
Every Promise lives in one of three states at any given moment:
| State | What It Means | Real World Analogy |
|---|---|---|
| Pending | The operation is still in progress | Buzzer in your hand, food still cooking |
| Fulfilled | The operation completed successfully | Buzzer goes off, food is ready |
| Rejected | The operation failed | Buzzer goes off, kitchen says they're out |
A Promise starts as pending and moves to either fulfilled or rejected — and it can only make that move once. Once a Promise is settled, it never changes state again.
A Promise is a one-way door. It starts pending, moves to fulfilled or rejected, and stays there forever.
🔗 then, catch, and finally
Once a Promise settles, you need a way to react to it. That's where .then(), .catch(), and .finally() come in.
myPromise
.then((data) => {
// runs if the Promise fulfilled
console.log(data); // "Here is your data!"
})
.catch((error) => {
// runs if the Promise rejected
console.log(error); // "Something went wrong."
})
.finally(() => {
// runs no matter what — fulfilled or rejected
console.log("Promise is settled.");
});
Each of these callbacks goes into the microtask queue when the Promise settles. As we saw in Post 1, microtasks always run before macrotasks — so Promise callbacks are always handled promptly, before any timers or UI events get a chance.
console.log("Start"); // synchronous
Promise.resolve("data")
.then((data) => {
console.log("Got:", data); // microtask
})
.catch(() => {
console.log("Error"); // won't run — Promise fulfilled
})
.finally(() => {
console.log("Done"); // microtask — runs after then
});
console.log("End"); // synchronous
Output:
Start
End
Got: data
Done
.catch() is skipped because the Promise fulfilled successfully. .finally() runs after .then() because it entered the microtask queue after .then() completed — chained callbacks queue one at a time, as we covered in Post 1.
🍽️ async/await — The Waiter Who Waits
Promises are powerful, but chaining many .then() calls together can get hard to read. async/await was introduced to solve that — it lets you write async code that reads like synchronous code.
Here's the key thing to understand upfront:
async/awaitis not a new thing. It's just cleaner syntax built on top of Promises. Same microtask queue, same rules — just easier to read.
Think of it like this. With Promises, the waiter takes your order, hands it to the kitchen, and walks away to handle other tables. With async/await, the waiter takes your order and waits at the kitchen window until it's ready before moving on.
Same food. Same kitchen. Just a different style of waiting.
Here's the same logic written both ways:
// With Promises
function getDataWithPromise() {
Promise.resolve("data")
.then((data) => {
console.log(data); // "data"
});
}
// With async/await
async function getDataWithAwait() {
const data = await Promise.resolve("data");
console.log(data); // "data"
}
Both do the exact same thing. await just means — pause this function right here, wait for the Promise to settle, then continue.
⏸️ await Pauses the Function — Not the Program
This is the most important thing to understand about async/await, and the part that trips most people up.
When JavaScript hits an await, it does two things:
- Pauses that function — everything after the
awaitgoes into the microtask queue - Steps out — goes back to the main code and keeps running whatever is next
It does NOT freeze your whole program. Only that one function pauses. Everything else keeps going.
console.log("Start"); // synchronous
async function myFunction() {
console.log("Inside function"); // synchronous — runs when function is called
await Promise.resolve(); // pauses HERE
console.log("After await"); // microtask — runs after call stack is empty
}
myFunction(); // function is called synchronously
console.log("End"); // synchronous — runs before After await
Output:
Start
Inside function
End
After await
See what happened? "Inside function" printed before "End" — because the function ran synchronously up until the await. Then JavaScript stepped out of the function and kept running the main code. Only after "End" printed — when the call stack was empty — did the microtask queue kick in and resume the function with "After await".
awaitis like a bookmark. JavaScript pauses the function, places a bookmark at that line, finishes everything else, then comes back to resume from the bookmark.
📞 await myFunction() vs Just Calling myFunction()
There's one more important distinction — what happens when you await a function call vs just calling it normally.
Without await — fire and move on:
async function main() {
console.log("Start");
myFunction(); // called but NOT awaited
console.log("End"); // runs immediately, doesn't wait for myFunction
}
async function myFunction() {
console.log("Inside function");
await Promise.resolve();
console.log("After await");
}
main();
Output:
Start
Inside function
End
After await
JavaScript called myFunction(), ran it until it hit await, stepped out, printed "End", then came back to finish myFunction.
With await — wait for it to fully finish:
async function main() {
console.log("Start");
await myFunction(); // wait for myFunction to fully complete
console.log("End"); // only runs AFTER myFunction is completely done
}
async function myFunction() {
console.log("Inside function");
await Promise.resolve();
console.log("After await");
}
main();
Output:
Start
Inside function
After await
End
Now "End" is last — because main() waited for myFunction() to fully complete before moving on.
Without
awaiton a function call — JavaScript starts the function and immediately moves on. Withawait— JavaScript stays put until that function is completely done.
One important rule — you can only use await inside a function marked as async. You can't use it in regular code.
🧩 The Complete Picture
Here's how Promises and async/await fit into the event loop we built in Post 1:
Synchronous code runs
↓
Promise settles → .then() / await resumption
enters microtask queue
↓
Microtask queue drains completely
↓
Macrotasks run (setTimeout, UI events)
↓
[repeat]
Whether you use .then() chaining or async/await — the behaviour is identical. Both put their callbacks into the microtask queue. Both follow the same golden rule. The only difference is how your code looks.
✅ Key Takeaways
- A Promise has three states — pending, fulfilled, and rejected — and can only settle once
.then()runs on fulfillment,.catch()on rejection,.finally()on either — all go into the microtask queueasync/awaitis not new — it's cleaner syntax built directly on top of Promisesawaitpauses the function, not the whole program — JavaScript steps out and keeps running- Without
awaiton a function call — JavaScript fires and moves on immediately - With
awaiton a function call — JavaScript waits for that function to fully complete - You can only use
awaitinside anasyncfunction
You now see Promises and async/await not as two different things — but as two ways of writing the same idea. One with chains, one with bookmarks. Both landing in the same microtask queue, following the same rules.
🔮 What's Next
In Post 3, we step outside the browser and into Node.js — a different environment for running JavaScript, with its own additions to the event loop: process.nextTick() and what I/O actually means.
This is Part 2 of a 5-part series on the JavaScript Event Loop.

