03 · Asynchronous JavaScript I¶
JavaScript runs on a single thread, yet it handles timers, network requests,
and user input without blocking. This module covers the mechanics that make
that possible — the foundation for Promises and async/await in the next
module.
Synchronous code and the call stack¶
JavaScript executes one thing at a time, in order, using a call stack: each function call is pushed on, and popped off when it returns.
function third() {
console.log("third");
}
function second() {
console.log("second");
third();
}
function first() {
console.log("first");
second();
}
first();
// first
// second
// third
Each call waits for the ones it invokes to finish completely before continuing — this is what "synchronous" and "blocking" mean.
Callbacks: functions passed to run later¶
A callback is simply a function passed as an argument, to be called at some later point — often after an operation finishes.
function fetchUserName(id, callback) {
console.log("starting lookup...");
setTimeout(() => {
// simulate work that takes time, e.g. a network request
callback(`user-${id}`);
}, 1000);
}
fetchUserName(42, (name) => {
console.log(`got: ${name}`); // runs ~1 second later
});
console.log("lookup requested, moving on"); // runs immediately, before the callback
Output order:
The last line prints after the synchronous code has already finished,
because setTimeout doesn't block — it schedules the callback for later.
The event loop, conceptually¶
Node.js and browsers use an event loop to coordinate the call stack with pending work. The mental model:
- Run everything on the call stack until it's empty.
- Check whether there's pending callback work ready to run.
- Push the next ready callback onto the stack and run it.
- Repeat forever.
console.log("1: sync");
setTimeout(() => console.log("3: timeout callback"), 0);
console.log("2: sync");
// 1: sync
// 2: sync
// 3: timeout callback
Even with a delay of 0, the timeout callback still runs after all
synchronous code — it has to wait for the call stack to empty first.
Task queue vs. microtask queue¶
Not all "later" work is treated equally. Promise callbacks (.then,
async/await continuations, covered next module) go into the microtask
queue, which the event loop always fully drains before touching the
task queue (also called the macrotask queue) that holds things like
setTimeout and DOM events.
console.log("1: sync start");
setTimeout(() => console.log("4: setTimeout (task queue)"), 0);
Promise.resolve().then(() => console.log("3: promise (microtask queue)"));
console.log("2: sync end");
// 1: sync start
// 2: sync end
// 3: promise (microtask queue)
// 4: setTimeout (task queue)
Even though both are scheduled with a "0 delay," the microtask always wins — all queued microtasks run before the next macrotask is picked up.
Callback hell — the problem Promises solve¶
Nesting callback after callback to sequence dependent async steps quickly becomes hard to read and error-prone (each level needs its own error handling).
function getUser(id, callback) {
setTimeout(() => callback({ id, name: "Ada" }), 100);
}
function getOrders(userId, callback) {
setTimeout(() => callback(["order-1", "order-2"]), 100);
}
function getOrderDetails(orderId, callback) {
setTimeout(() => callback(`${orderId}: shipped`), 100);
}
// Deeply nested — the "pyramid of doom"
getUser(1, (user) => {
getOrders(user.id, (orders) => {
getOrderDetails(orders[0], (details) => {
console.log(details); // order-1: shipped
});
});
});
Each step must wait on the previous one, and error handling would need a check at every single level. Promises (next module) exist specifically to flatten this structure and centralize error handling.
Queue behavior cheat sheet¶
| Source | Queue | Runs relative to sync code | Runs relative to other queue |
|---|---|---|---|
console.log, loops, direct calls |
call stack | immediately | — |
Promise.then/catch/finally, async continuations |
microtask queue | after current sync code finishes | before any pending macrotask |
setTimeout, setInterval, DOM events |
task (macro) queue | after current sync code finishes | after all pending microtasks drain |
How It Actually Works¶
JavaScript has exactly one call stack, so "asynchronous" never means "runs in
parallel inside your code" — it means "the browser/Node runtime does the waiting, and
schedules your callback later." setTimeout(fn, 0) doesn't run fn immediately even
with a zero delay: it hands the timer to the host environment (libuv in Node, the
browser's timer thread), and when the delay elapses, the runtime places fn on the
macrotask queue (also called the task queue). The event loop only pulls a
macrotask off that queue when the call stack is completely empty — so a synchronous
loop that runs for 5 seconds will delay even a setTimeout(fn, 0) callback by the same
5 seconds, no matter how small the requested delay was.
Callbacks passed to things like fs.readFile or an XHR's onload follow the same
model but through different queues depending on the API — Node's I/O callbacks are
scheduled by libuv's thread pool completing work off the main thread and then posting
the JS callback back onto the main event loop's poll phase. The key mental model: your
JS code is never actually running concurrently with anything else — every callback,
however it got scheduled, runs to completion on the single main thread before the next
one starts, which is why a single infinite while(true) loop anywhere freezes every
timer, every event, and every I/O callback in the whole program.
Exercise¶
Predict the exact console output order of the snippet below, then run it to check your answer: