JavaScript has exactly one call stack. There is no thread pool backing your async code, no implicit parallelism happening on your behalf. What makes it feel concurrent is a scheduling loop that interleaves your synchronous code with callbacks handed back by the browser or Node's runtime — and the rules governing that interleaving are precise, not a vague best-effort. Get the rules wrong in your mental model and you'll misdiagnose real production bugs as 'flaky' when they're actually deterministic.
The Four Pieces: Stack, Heap, Queues, and the Loop Itself
The call stack is a LIFO structure tracking function execution frames — nothing async lives here. When you call fetch(), the function that initiates the request runs and returns immediately; the actual network operation is handed off to a Web API (browser) or libuv (Node), which runs outside the JS thread entirely. Only when that operation completes does its callback get placed into a queue, waiting for the stack to empty before it can run.
The Moving Parts
- Call Stack: synchronous execution frames only, strictly LIFO — nothing here waits on I/O; if it's on the stack, it runs to completion or throws.
- Web APIs / libuv thread pool: where the actual asynchronous work happens — timers, network requests, file I/O, DOM events — entirely outside the single JS thread.
- Macrotask (task) queue: holds completed setTimeout/setInterval callbacks, I/O callbacks, UI rendering, and message events, one of which is processed per loop tick.
- Microtask queue: holds Promise .then/.catch/.finally callbacks and queueMicrotask() calls — this queue is drained completely, not one-at-a-time, before the loop moves on.
- Event Loop: the scheduler that, on every tick, checks if the call stack is empty, and if so, drains the entire microtask queue before pulling a single task off the macrotask queue.
Why Microtasks Beat Macrotasks, Every Time
This is the ordering rule that trips people up in interviews and in production alike: a resolved Promise's .then() callback will always run before a setTimeout(fn, 0) callback, no matter how the code is written, because they're not competing in the same queue. The event loop's contract is to fully empty the microtask queue after every single synchronous execution chunk — including any new microtasks that get enqueued while draining — before it's even allowed to look at the macrotask queue again.
console.log('1 - sync');
setTimeout(() => console.log('4 - macrotask'), 0);
Promise.resolve()
.then(() => console.log('3 - microtask A'))
.then(() => console.log('3.5 - microtask B, enqueued during drain'));
console.log('2 - sync');
// Output: 1, 2, 3 - microtask A, 3.5 - microtask B, 4 - macrotask
// The second .then() is enqueued WHILE the microtask queue is being
// drained, and it still runs before the setTimeout callback — the
// queue isn't drained "once", it's drained until truly empty.async/await Is Promise Machinery With Better Syntax
An async function always returns a Promise, and every await inside it is a suspension point: the function's execution pauses, control returns to the caller, and the remainder of the function is scheduled to resume as a microtask once the awaited value settles. There is no new concurrency primitive here — await desugars directly onto the Promise/microtask machinery already covered above, which is exactly why the ordering rules from the previous section still apply inside async functions.
async function loadUser(id) {
console.log('start');
const res = await fetch(`/api/users/${id}`); // suspends here, yields to caller
console.log('after fetch'); // resumes as a microtask
return res.json();
}
// Roughly equivalent to:
function loadUserDesugared(id) {
console.log('start');
return fetch(`/api/users/${id}`).then(res => {
console.log('after fetch');
return res.json();
});
}
loadUser(1);
console.log('called loadUser, but it has not resumed yet');
// Output order: start, called loadUser..., after fetch
// The caller keeps running past the await point immediately —
// awaiting doesn't block the thread, it just schedules a resumption.A Real Bug: Sequential Awaits That Should Have Been Parallel
The most common performance bug in async code isn't about the event loop's ordering rules at all — it's forgetting that each await is a real suspension point, and stacking them sequentially when the underlying operations don't actually depend on each other. This shows up constantly in API route handlers that fetch multiple independent resources.
// BAD: each await blocks the next request from starting.
// Three round trips, paid sequentially — if each takes 200ms, this is 600ms.
app.get('/dashboard/:userId', async (req, res) => {
const profile = await db.getProfile(req.params.userId);
const orders = await db.getOrders(req.params.userId);
const notifications = await db.getNotifications(req.params.userId);
res.json({ profile, orders, notifications });
});
// GOOD: all three requests are issued in the same synchronous tick,
// so they run concurrently on the I/O side. Promise.all suspends
// once, resuming only when the slowest of the three settles — ~200ms total.
app.get('/dashboard/:userId', async (req, res) => {
const [profile, orders, notifications] = await Promise.all([
db.getProfile(req.params.userId),
db.getOrders(req.params.userId),
db.getNotifications(req.params.userId),
]);
res.json({ profile, orders, notifications });
});Practical Rules for Async Code Under Load
- Only use sequential await when the second call genuinely needs the first call's result — otherwise you're paying network latency serially for no reason.
- Promise.all rejects as soon as any one input rejects, discarding the results of the others — use Promise.allSettled when partial failure is acceptable and you need every result regardless of individual failures.
- A synchronous CPU-heavy loop (JSON-parsing a huge payload, a tight numeric computation) blocks the call stack exactly like any other synchronous code, even inside an async function — async does not mean 'off the main thread,' it only means 'not blocking on I/O completion.'
- In Node, offload genuinely CPU-bound work to a worker_thread; awaiting a Promise never moves computation off the main JS thread by itself.
Closing: The Loop Is a Contract You Can Rely On
The event loop isn't a vague scheduling heuristic — it's a small set of deterministic rules: one stack, async work happens off-thread, microtasks drain completely before every macrotask, and nothing runs concurrently on the JS thread itself. Once that model is solid, 'weird' async bugs stop being weird. A callback firing in an unexpected order, a UI that freezes despite low CPU usage, an API route that's slower than it should be — all of these trace back to one of the rules above being violated or misunderstood, not to JavaScript behaving unpredictably.
The practical discipline that follows: know whether the code you're writing is a suspension point or a blocking computation, know which queue a given callback lands in, and default to concurrent awaits unless there's a genuine data dependency forcing sequence. That's the entire mental model — everything else, from Node's process.nextTick priority quirks to React's own scheduling on top of this loop, is a layer built on these same four pieces.