why async code does not run in the order you expect
JavaScript is single-threaded, but not sequential. The Event Loop determines the execution order of synchronous code, Promises, timers and callbacks. Anyone who does not understand the difference between microtasks and macrotasks ends up writing asynchronous code that runs in a mysteriously wrong order.
Table of Contents
- 1. JavaScript is single-threaded, what does that actually mean?
- 2. The Call Stack: how JavaScript executes functions
- 3. Macrotasks: setTimeout, setInterval and I/O callbacks
- 4. Microtasks: Promise callbacks and queueMicrotask
- 5. The execution order: what happens when?
- 6. async/await and the Event Loop: what await really does
- 7. The Event Loop in Node.js: libuv and phases
- 8. Task starving: when microtasks block the Event Loop
- 9. Microtasks vs. macrotasks side by side
- 10. Summary
- 11. FAQ
1. JavaScript is single-threaded, what does that actually mean?
JavaScript has exactly one Call Stack and executes exactly one block of code at any given moment. There is no parallelism in the strict sense: no threads, no simultaneous execution of multiple JavaScript functions. This is a deliberate design decision that rules out concurrency bugs such as race conditions and deadlocks from the outset. But how can JavaScript then wait on network responses, manage timers and react to user interactions at the same time, without freezing up? The answer lies in the Event Loop.
The JavaScript Event Loop is a mechanism that checks: is the Call Stack empty? If so, are there tasks waiting in the queues that need to run? The actual asynchronous work, network requests, timers, I/O, is not handled by JavaScript itself but by the runtime environment: by Web APIs in the browser, by libuv in Node.js. Once that asynchronous operation completes, its callback is not run immediately but placed into a queue. The Event Loop picks it up as soon as the Call Stack is free. The distinction between different queues, the microtask queue and the macrotask queue, is the central concept that determines execution order in JavaScript.
2. The Call Stack: how JavaScript executes functions
The Call Stack is a LIFO data structure (Last In, First Out) onto which JavaScript pushes and pops function calls. When a function is called, a "stack frame" is placed on top of the stack containing the function's execution context: local variables, the current execution point and the return address. When the function returns, its frame is removed. A function that calls another function sits below it on the stack, and JavaScript waits until the called function returns before continuing. This is entirely synchronous.
A "stack overflow", the error message you get with infinite recursion, occurs when so many frames are pushed onto the stack that the allotted memory limit is exceeded. The Call Stack has a finite size. As long as the Call Stack is not empty, the Event Loop cannot pull new tasks from the queues. That is why a blocking while(true) loop freezes the entire page: the stack is never empty, the Event Loop never gets a turn, and no callbacks, no events, no repaints can happen.
// Execution order puzzle, what gets logged and in what order?
console.log('1, synchronous');
setTimeout(() => console.log('2, macrotask (setTimeout 0)'), 0);
Promise.resolve()
.then(() => console.log('3, microtask (Promise.then)'))
.then(() => console.log('4, microtask (chained .then)'));
queueMicrotask(() => console.log('5, microtask (queueMicrotask)'));
console.log('6, synchronous');
// OUTPUT ORDER:
// 1, synchronous
// 6, synchronous
// 3, microtask (Promise.then)
// 4, microtask (chained .then)
// 5, microtask (queueMicrotask)
// 2, macrotask (setTimeout 0)
// WHY:
// All synchronous code runs first (1, 6)
// After each task, ALL microtasks are drained before the next macrotask
// queueMicrotask queues into the microtask queue, same as Promise.then
// setTimeout(fn, 0) is a macrotask, runs AFTER all current microtasks
3. Macrotasks: setTimeout, setInterval and I/O callbacks
Macrotasks (also called "tasks" or "macrotasks") are the basic unit of work in the Event Loop. Each iteration of the Event Loop executes exactly one macrotask. Macrotasks include: setTimeout and setInterval callbacks, I/O callbacks (file read responses, network events), UI rendering callbacks, and MessageChannel messages. After executing a macrotask, the Event Loop checks the microtask queue and drains it completely before picking up the next macrotask.
A common misconception: setTimeout(fn, 0) does not run the callback immediately or on the next tick. It enqueues it as a macrotask with a minimum delay (typically 4ms in browsers after the first nesting level). The callback only runs once the current code has finished, all microtasks have been processed, and the Event Loop moves on to the next macrotask. So setTimeout(fn, 0) does not mean "as soon as possible", it means "after all microtasks of the current round".
4. Microtasks: Promise callbacks and queueMicrotask
Microtasks are fully drained after every macrotask and, in the browser, after every task, before the Event Loop continues. Microtasks include: Promise.then(), Promise.catch() and Promise.finally() callbacks, queueMicrotask() calls, and MutationObserver callbacks. The crucial difference from macrotasks: if a microtask enqueues further microtasks, for example when a then callback returns a new Promise and another then is chained onto it, those new microtasks still run within the current round, before the next macrotask gets a turn.
queueMicrotask(callback) has been directly available since Chrome 71 and Node.js 11 and lets you explicitly enqueue microtasks without using a Promise construct. That is useful whenever you want to make sure a callback runs after the current synchronous code but before the next rendering step. Subscribing to DOM changes via MutationObserver also uses the microtask queue, which explains why observer callbacks fire right after DOM changes without waiting for the next Event Loop pass.
5. The execution order: what happens when?
The complete execution order in the JavaScript Event Loop can be summarized in four steps. First step: synchronous code on the Call Stack runs to completion. Second step: the microtask queue is drained completely, and any callback that enqueues new microtasks along the way is also executed within this same round. Third step (browser only): rendering, style calculation, layout, paint, if needed. Fourth step: one macrotask is taken from the task queue and executed, then back to step 2.
This has important practical consequences. A Promise.resolve().then() always runs before a setTimeout(fn, 0), even if the setTimeout call appears earlier in the code. DOM updates made inside microtasks only become visible to the user after the rendering step, because rendering happens after the microtask queue but before the next macrotask. That means: multiple DOM updates in consecutive then callbacks all happen before the next repaint, which can be both an advantage and a disadvantage. Advantage: a single repaint for multiple updates. Disadvantage: the user sees no intermediate states, which can look like the page "freezing" during long chains of computation.
// Practical Event Loop example: understanding async ordering in real code
async function loadAndRender(url) {
console.log('A, sync: function called');
const data = await fetch(url).then(r => r.json()); // microtask when fetch resolves
console.log('B, after await fetch: runs in microtask continuation');
// DOM update happens here, but is NOT painted yet
document.querySelector('#result').textContent = JSON.stringify(data);
console.log('C, DOM updated, but no repaint yet');
// requestAnimationFrame schedules a macrotask before the NEXT paint
requestAnimationFrame(() => {
console.log('D, rAF callback: runs just before the next paint');
});
// setTimeout runs AFTER rAF and AFTER the paint
setTimeout(() => {
console.log('E, setTimeout: runs as macrotask after paint');
}, 0);
}
// Output order when fetch is fast:
// A, sync: function called
// [fetch completes, .then().then() microtasks drain, await resumes]
// B, after await fetch
// C, DOM updated, but no repaint yet
// [microtask queue empty, rendering step: repaint]
// D, rAF callback (before next paint)
// [render]
// E, setTimeout (macrotask after paint)
// Understanding this order is critical for:
//, Avoiding unnecessary layout recalculations
//, Batching DOM updates efficiently
//, Coordinating animations with data loading
6. async/await and the Event Loop: what await really does
async/await is syntactic sugar for Promises, but it is important to understand exactly what await does to the Event Loop. When JavaScript hits an await keyword, it pauses execution of the async function and hands control back to the caller, as if the function returned a value (a pending Promise) at that point. The rest of the async function, everything after the await, is enqueued as a microtask once the awaited Promise resolves.
A common misconception: await Promise.resolve(value) is not the same as a synchronous assignment. It hands control back and enqueues the continuation as a microtask, even if the Promise is already resolved. That means: two consecutive await calls inside a function create two microtask suspensions. Code that comes after an await never runs synchronously with the code before it in the same "tick", it always runs only after the current synchronous code has finished. That is why an async function always returns a Promise, even if the return value itself is not a Promise: the function is structurally asynchronous by design.
7. The Event Loop in Node.js: libuv and phases
The Event Loop in Node.js differs from the browser Event Loop in one important dimension: it has explicit phases, implemented by the libuv library. The main phases are: "timers" (setTimeout, setInterval), "pending callbacks" (I/O errors from the previous loop), "idle/prepare" (internal), "poll" (retrieve and execute I/O callbacks), "check" (setImmediate callbacks) and "close callbacks" (socket.on('close') callbacks). The microtask queue in Node.js is drained between every phase of the Event Loop.
Node.js has two microtask queues: process.nextTick() callbacks and Promise callbacks. process.nextTick() has higher priority than Promise callbacks and runs before them. That is Node.js-specific behavior that does not exist in the browser. setImmediate() in Node.js is a check-phase macrotask that fires after I/O callbacks within the same poll round. The difference between setTimeout(fn, 0) and setImmediate(fn) in Node.js is context-dependent: inside I/O callbacks, setImmediate always fires first; at the top level, the order is not deterministic.
// Node.js specific: process.nextTick vs Promise vs setImmediate vs setTimeout
// Demonstrates Node.js microtask priority ordering
process.nextTick(() => console.log('1, nextTick (microtask, highest priority)'));
Promise.resolve().then(() => console.log('2, Promise.then (microtask)'));
setImmediate(() => console.log('3, setImmediate (check phase macrotask)'));
setTimeout(() => console.log('4, setTimeout 0 (timer phase macrotask)'), 0);
console.log('5, synchronous');
// Node.js OUTPUT ORDER:
// 5, synchronous
// 1, nextTick (runs before Promise.then in Node.js)
// 2, Promise.then
// 3, setImmediate OR 4, setTimeout (order not guaranteed at top level)
// Inside an I/O callback, setImmediate always fires before setTimeout:
const fs = require('fs');
fs.readFile(__filename, () => {
setImmediate(() => console.log('setImmediate inside I/O, always first'));
setTimeout(() => console.log('setTimeout inside I/O, always second'), 0);
});
// Anti-pattern: recursive nextTick starves the event loop
function badRecursion() {
process.nextTick(badRecursion); // NEVER do this, starves all I/O
}
// Safe alternative: setImmediate for recursive async operations
function safeRecursion() {
setImmediate(safeRecursion); // allows I/O between iterations
}
8. Task starving: when microtasks block the Event Loop
"Task starving" or "microtask starving" is a serious problem that arises when the microtask queue never empties because every microtask enqueues another microtask. Since the Event Loop only moves on to the next macrotask once the microtask queue is completely drained, macrotasks such as I/O callbacks, UI events and timers can be starved indefinitely. In extreme cases, the entire application freezes: no rendering, no events, no network responses.
A classic example: a loop that calls Promise.resolve().then() on every step, and inside the then callback enqueues yet another Promise. Because no macrotask ever gets a chance to run in between, the rendering step is skipped until the loop finishes. For compute-heavy work, the right pattern is to split the work into macrotasks, either with setTimeout(chunk, 0) for compatibility, or with scheduler.yield() (where available) for even finer-grained control. Microtasks are meant for fast, non-blocking operations, not for iterative computation.
9. Microtasks vs. macrotasks side by side
The differences between microtasks and macrotasks are fundamental to understanding JavaScript execution order.
| Property | Microtasks | Macrotasks | Examples |
|---|---|---|---|
| Execution timing | After the current task, before rendering | After rendering, one per loop | n/a |
| Queue draining | Complete (including new microtasks) | One per Event Loop iteration | n/a |
| APIs (browser) | Promise.then, queueMicrotask, MutationObserver | setTimeout, setInterval, fetch, I/O | n/a |
| APIs (Node.js) | process.nextTick, Promise.then | setTimeout, setImmediate, fs, net | n/a |
| Starving risk | High (blocks rendering and I/O) | None (one per round) | n/a |
Understanding this table lets you predict execution order in any piece of JavaScript code with precision. Complex asynchronous bugs, where code runs in the wrong order or DOM updates fail to appear, can usually be traced directly back to these rules. Once you have internalized this mechanic, you read asynchronous JavaScript code on a whole different level.
Mironsoft
JavaScript development, async architecture and performance optimization
Need to solve async JavaScript problems professionally?
We analyze complex asynchronous JavaScript architectures, identify race conditions, task starving problems and Promise chain bugs, and implement robust, predictable async patterns.
Async code review
Analyzing Promise chains, async/await patterns and Event Loop pitfalls
Performance optimization
Identifying main thread blocking and fixing it through proper task splitting
Team training
Event Loop, microtasks and macrotasks as a workshop for JavaScript teams
10. Summary
The JavaScript Event Loop is the core of the asynchronous programming model. It coordinates the Call Stack, the microtask queue and the macrotask queue in a fixed sequence: synchronous code runs to completion, then all microtasks are drained (including new microtasks created along the way), then, in the browser, rendering happens, then one macrotask runs. Understanding this sequence is the difference between asynchronous code that behaves as expected and code that runs in a mysteriously wrong order.
Microtasks (Promise callbacks, queueMicrotask) run before the next rendering step and before the next macrotask. Macrotasks (setTimeout, setInterval, I/O) are executed one at a time per Event Loop iteration. In Node.js, process.nextTick has higher priority than Promise callbacks. Task starving caused by recursive microtasks blocks rendering and I/O. async/await pauses execution at every await and enqueues the continuation as a microtask. These rules apply consistently across every JavaScript runtime, and knowing them means writing better, more predictable asynchronous code.
JavaScript Event Loop, the essentials at a glance
Execution order
1. Synchronous code. 2. All microtasks (including new ones). 3. Rendering (browser). 4. One macrotask. Then back to the start.
Microtasks
Promise.then, queueMicrotask, MutationObserver. Run to completion before rendering or the next macrotask. Can be used recursively, but with a starving risk.
Macrotasks
setTimeout, setInterval, I/O, UI events. Exactly one per Event Loop round. setTimeout(fn,0) is not immediate, it runs only after all current microtasks.
Node.js specifics
process.nextTick has higher priority than Promise.then. setImmediate fires in the check phase after I/O. setTimeout vs. setImmediate order at the top level is not deterministic.