Async JavaScript
The event loop, Promises, async/await, Promise.all, and common async pitfalls to avoid.
The event loop, conceptually
JavaScript is single-threaded — one call stack, executing one thing at a time. Yet it handles thousands of concurrent network requests and timers without blocking. The event loop is the mechanism that makes this possible:
- Synchronous code runs on the call stack, top to bottom, immediately.
- Asynchronous operations (a
fetchcall, asetTimeout, a file read in Node) are handed off to the browser/Node runtime, which does the actual waiting outside the JS engine. - When that operation finishes, its callback is placed into a queue — not run immediately.
- The event loop constantly checks: "is the call stack empty?" Once it is, it pulls the next callback off the queue and runs it.
There are actually two queues, checked with different priority:
- The microtask queue — Promise callbacks (
.then,.catch,async/awaitcontinuations). Fully drained after every synchronous execution, before the event loop moves on. - The macrotask queue —
setTimeout,setInterval, I/O callbacks, UI events. One macrotask runs per event loop tick, after the microtask queue is empty.
console.log("1: sync");
setTimeout(() => console.log("2: macrotask (setTimeout)"), 0);
Promise.resolve().then(() => console.log("3: microtask (Promise)"));
console.log("4: sync");
// Output order: 1, 4, 3, 2
// All synchronous code runs first, then ALL microtasks, then macrotasks —
// even though the setTimeout delay is 0.
This ordering — sync, then all microtasks, then the next macrotask — is one of the most commonly tested JavaScript concepts in interviews.
Callbacks (a brief history)
Before Promises existed (pre-ES2015), asynchronous code passed a callback function to be invoked later:
// Old style — still seen in older Node APIs
fs.readFile("data.txt", (err, data) => {
if (err) return console.error(err);
console.log(data);
});
Nesting several dependent async steps this way produces "callback hell" — deeply indented, hard-to-follow pyramids of callbacks-within-callbacks. Promises and async/await exist specifically to solve this readability problem. You'll still encounter callback-style APIs in legacy code, but don't write new code this way.
Promises
A Promise represents a value that will be available eventually — either fulfilled (success) or rejected (failure):
function fetchUser(id) {
return new Promise((resolve, reject) => {
setTimeout(() => {
if (id > 0) {
resolve({ id, name: "Ada" });
} else {
reject(new Error("Invalid id"));
}
}, 500);
});
}
fetchUser(1)
.then(user => console.log(user)) // runs on success
.catch(err => console.error(err)) // runs on failure
.finally(() => console.log("Done")); // always runs
Chaining .then() calls is how sequential async steps compose — each .then receives the previous one's resolved value:
fetchUser(1)
.then(user => fetchOrders(user.id))
.then(orders => console.log(orders))
.catch(err => console.error("Something failed:", err));
async/await
async/await is syntactic sugar over Promises that lets asynchronous code read like ordinary synchronous code:
async function loadUserWithOrders(id) {
try {
const user = await fetchUser(id); // pauses here until the Promise settles
const orders = await fetchOrders(user.id);
return { user, orders };
} catch (err) {
console.error("Something failed:", err);
throw err;
}
}
An async function always returns a Promise, even if you return a plain value inside it. await can only be used inside an async function (or, since ES2022, at the top level of a module).
Running things in parallel: Promise.all
Awaiting multiple independent Promises one at a time is a common — and costly — mistake:
// Sequential — total time ≈ sum of all three delays (slow!)
async function loadAllSequential() {
const users = await fetchUsers(); // waits for this to finish...
const products = await fetchProducts(); // ...before even starting this
const orders = await fetchOrders();
return { users, products, orders };
}
// Parallel — total time ≈ the single slowest of the three (fast!)
async function loadAllParallel() {
const [users, products, orders] = await Promise.all([
fetchUsers(),
fetchProducts(),
fetchOrders(),
]);
return { users, products, orders };
}
Promise.all starts all the given Promises immediately and resolves once every one succeeds — if any one rejects, the whole thing rejects immediately. Promise.allSettled is the alternative when you want the results of every Promise regardless of individual failures.
Common pitfalls
- Accidentally sequential awaits — awaiting independent operations one after another instead of starting them together with
Promise.all, needlessly multiplying total wait time. - Unhandled promise rejections — a
.then()chain (or anasyncfunction) with no.catch()/try...catchsilently swallows or crashes on an error depending on the environment. Always handle rejections explicitly. - Forgetting
await— calling anasyncfunction withoutawaitreturns the Promise itself, not its resolved value, and the calling code moves on before the operation actually finishes.
async function badExample() {
const result = fetchUser(1); // BUG: missing await
console.log(result); // logs "Promise { <pending> }", not the user object
}
Interview questions
Q: What's the difference between the microtask queue and the macrotask queue?
Microtasks (Promise callbacks, queueMicrotask) are fully drained after every synchronous execution completes, before the event loop proceeds to anything else. Macrotasks (setTimeout, setInterval, I/O events) run one at a time, only after the microtask queue is empty. This is why a Promise.resolve().then(...) always runs before a setTimeout(..., 0), even though both are "asynchronous."
Q: What does Promise.all do if one of the Promises rejects?
Promise.all rejects immediately with that first rejection reason, even if the other Promises are still pending — it doesn't wait for all of them to settle. If you need the outcome of every Promise regardless of individual failures, use Promise.allSettled instead, which always resolves with an array describing each Promise's status.