JavaScript is single-threaded, yet it can:
- Fetch data from servers
- Read files
- Handle user clicks
- Run timers
- Animate UI
All without freezing the browser.
How? Through Promises, async/await, and the Event Loop.
This guide is written to make the “why is my code running out of order?” problem finally click. We’ll cover:
- What synchronous vs asynchronous code means
- What Promises are (and what they aren’t)
- How
async/awaitworks under the hood - How the Event Loop decides what runs next
- Microtasks vs macrotasks (why Promises often run before timers)
- Common mistakes and best practices
- Real-world patterns (parallel requests, error handling)
🧠 1) Synchronous vs Asynchronous JavaScript
✅ Synchronous (blocking, predictable order)
console.log("A");
console.log("B");
console.log("C");
Output:
A
B
C
Each line waits for the previous one to finish.
❌ The real world has slow work
Network requests, file reads, databases, timers—these don’t finish instantly. If JavaScript “waited” for them the same way, your app would freeze.
Example:
console.log("Start");
setTimeout(() => {
console.log("Done");
}, 3000);
console.log("End");
Output:
Start
End
Done
JavaScript doesn’t pause the whole program for setTimeout. It schedules the callback to run later.
Important idea:
Asynchronous code lets JavaScript keep the UI responsive and continue running other tasks while waiting for slow operations.
🌟 2) What is a Promise?
A Promise represents a value you don’t have yet, but will have later (or an error if it fails).
A Promise can be in one of three states:
- ⏳ Pending: still working
- ✅ Fulfilled: completed successfully (resolved)
- ❌ Rejected: failed (rejected)
A simple Promise
const myPromise = new Promise((resolve, reject) => {
setTimeout(() => {
resolve("Data received!");
}, 2000);
});
myPromise.then(result => {
console.log(result);
});
After 2 seconds you’ll see:
Data received!
Resolve vs Reject (success vs failure)
function doWork(success) {
return new Promise((resolve, reject) => {
setTimeout(() => {
if (success) resolve("✅ Work succeeded");
else reject(new Error("❌ Work failed"));
}, 1000);
});
}
doWork(true)
.then(msg => console.log(msg))
.catch(err => console.log(err.message));
Promise mental model (easy)
Think: “I’ll give you a result later.”
You attach handlers for later using:
-
.then(...)for success -
.catch(...)for errors -
.finally(...)for cleanup
🟡 3) Consuming Promises with .then() / .catch()
Promises are commonly used with .then() and .catch():
fetch("https://jsonplaceholder.typicode.com/users")
.then(response => response.json())
.then(users => {
console.log("Users:", users.length);
})
.catch(error => {
console.log("Fetch error:", error);
});
Why chaining can get messy
This works, but long chains can become harder to read, especially with multiple steps and error handling.
That’s why async/await exists: it’s Promise syntax that reads more like normal code.
✨ 4) async / await — Promises, but readable
The rules (must know)
-
asyncbefore a function means the function always returns a Promise -
awaitcan only be used inside anasyncfunction (with some modern exceptions like top-level await in modules) -
awaitpauses only the async function, not the entire program
The same fetch example using async/await
async function getUsers() {
const response = await fetch("https://jsonplaceholder.typicode.com/users");
const users = await response.json();
console.log("Users:", users.length);
}
getUsers();
Cleaner, right?
✅ Key: await does NOT freeze JavaScript
This example proves it:
function wait(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
async function demo() {
console.log("1. inside demo: start");
await wait(2000);
console.log("3. inside demo: after await");
}
console.log("0. outside: before demo");
demo();
console.log("2. outside: after demo");
Output:
0. outside: before demo
1. inside demo: start
2. outside: after demo
(wait 2 seconds)
3. inside demo: after await
What happened?
- The async function
demo()paused atawait wait(2000) - But the code outside continued running immediately
So no: your entire program does not “get stuck.” Only the async function waits.
🧯 5) Error handling with try/catch
With .then() you typically handle errors using .catch().
With async/await, the clean pattern is try/catch:
async function loadUser() {
try {
const res = await fetch("https://jsonplaceholder.typicode.com/users/1");
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const user = await res.json();
console.log("User:", user.name);
} catch (err) {
console.log("Failed:", err.message);
}
}
loadUser();
Tip: Always check res.ok for fetch. Fetch only rejects for network errors; HTTP 404/500 still resolve as responses.
🔄 6) The Event Loop — the “traffic controller” of JavaScript
If JavaScript is single-threaded, how does it handle asynchronous work?
It uses a system involving:
- Call Stack (what is running right now)
- Web APIs (browser features like timers, network)
- Task Queues (where callbacks wait)
- Event Loop (moves callbacks onto the stack when ready)
The moving parts (in simple words)
- Call Stack: Executes your JS functions line by line.
- Web APIs: Handles async operations (timers, fetch, DOM events) outside the stack.
- Queues: When async work finishes, callbacks are placed into queues.
- Event Loop: When the stack is empty, it pushes queued callbacks back onto the stack to run.
⚡ 7) Microtasks vs Macrotasks (why Promises run before timers)
There are (at least) two important queues:
-
Microtask Queue — Promise callbacks (
.then,awaitcontinuation),queueMicrotask,MutationObserver -
Macrotask (Task) Queue —
setTimeout,setInterval, UI events, message events
Priority rule:
When the call stack becomes empty:
- Run all microtasks first (drain microtask queue)
- Then run one macrotask
- Then microtasks again
- Repeat
This is why Promise callbacks often run before setTimeout(..., 0).
Example: Promise vs setTimeout
console.log("Start");
setTimeout(() => console.log("Timeout"), 0);
Promise.resolve().then(() => console.log("Promise"));
console.log("End");
Output:
Start
End
Promise
Timeout
Explanation:
- Synchronous logs run first (
Start,End) - Promise
.thengoes to microtask queue - setTimeout callback goes to macrotask queue
- Event Loop drains microtasks first →
Promise - Then runs timer →
Timeout
🧩 8) How async/await uses the Event Loop
await is basically syntax sugar for Promise chaining. Internally, code after await becomes a .then(...) continuation (a microtask).
Example:
async function demo() {
console.log("A");
await Promise.resolve();
console.log("B");
}
console.log("C");
demo();
console.log("D");
Output:
C
A
D
B
Why?
-
demo()starts immediately → printsA - hits
await→ schedules the rest (console.log("B")) as a microtask - control returns to the main flow → prints
D - microtask runs → prints
B
🧪 9) A more “realistic” event loop demo
Try predicting the output before reading it:
console.log("1");
setTimeout(() => console.log("2 - timeout"), 0);
Promise.resolve().then(() => console.log("3 - promise"));
(async () => {
console.log("4 - async start");
await Promise.resolve();
console.log("5 - async after await");
})();
console.log("6");
Expected output:
1
4 - async start
6
3 - promise
5 - async after await
2 - timeout
Reason:
- Synchronous:
1,4,6 - Microtasks: promise then (
3) and async continuation (5) - Macrotask: timeout (
2)
🧰 10) Real-world patterns you should know
✅ Run async tasks in parallel (faster)
Bad (sequential): waits for the first fetch before starting the second:
const a = await fetch(url1);
const b = await fetch(url2);
Good (parallel): start both immediately, wait together:
const [res1, res2] = await Promise.all([
fetch(url1),
fetch(url2),
]);
const [data1, data2] = await Promise.all([
res1.json(),
res2.json(),
]);
✅ Promise.allSettled (when you want all results, even failures)
const results = await Promise.allSettled([
fetch(url1),
fetch("bad-url"),
fetch(url3),
]);
results.forEach((r, i) => {
if (r.status === "fulfilled") console.log(i, "ok");
else console.log(i, "failed:", r.reason);
});
⚠️ 11) Common mistakes (and fixes)
Mistake 1: Using await outside async
const res = await fetch(url); // ❌ SyntaxError (in most contexts)
Fix:
async function run() {
const res = await fetch(url);
}
Mistake 2: Forgetting to handle errors
If you don’t catch errors, they become unhandled rejections and can crash Node apps or cause confusing browser console errors.
Fix:
try {
const res = await fetch(url);
} catch (err) {
console.log(err);
}
Mistake 3: Assuming fetch rejects on 404/500
Fetch resolves even for many HTTP errors.
Fix:
if (!res.ok) throw new Error(`HTTP ${res.status}`);
Mistake 4: forEach + await doesn’t do what you think
items.forEach(async item => {
await doWork(item); // ❌ doesn't wait the way you expect
});
Fix (sequential):
for (const item of items) {
await doWork(item);
}
Fix (parallel):
await Promise.all(items.map(doWork));
🧾 12) Quick cheat sheet
- Promise: future value (resolve/reject)
-
.then/.catch: handle promise outcome -
async: function returns a Promise -
await: pause inside that async function until promise resolves - Event Loop: decides what runs next when stack is empty
- Microtasks: promises/await continuations (run first)
- Macrotasks: timers/events (run after microtasks)
🎯 Conclusion
Once you understand Promises, async/await, and the Event Loop, JavaScript becomes predictable instead of magical.
You’ll debug faster, write cleaner code, and avoid classic async bugs—especially in:
- React / React Native
- Node.js APIs
- Any app that fetches data or handles events
If you remember only one thing:
awaitpauses only the async function. The event loop keeps JavaScript moving.
Bonus: Interview one-liner
“JavaScript uses the event loop to coordinate asynchronous operations. Promise callbacks and
awaitcontinuations go into the microtask queue, which runs after the call stack is empty and before macrotask callbacks likesetTimeout.”

Top comments (0)