What you'll learn
Quick Answer
JavaScript is single threaded, so only one piece of code runs at a time on the call stack. Asynchronous work is handed to the browser or Node, which puts a callback in a queue when it finishes. The event loop moves queued callbacks onto the stack whenever the stack is empty. Microtasks such as promise callbacks are drained completely before the next macrotask like setTimeout, which is why a resolved promise always logs before a zero-millisecond timeout.
Single Threaded, But Not Blocking
JavaScript has one call stack, so exactly one thing executes at a time. That sounds like it should make network requests freeze the page — and it would, if requests were handled by JavaScript itself.
They are not. When you call setTimeout or fetch, JavaScript hands the work to the surrounding environment — Web APIs in the browser, libuv in Node — which handles timers, network and file I/O outside your single thread. Your code carries straight on.
When that external work finishes, its callback is placed in a queue. The event loop does one job: whenever the call stack is empty, take the next callback from the queue and push it onto the stack.
Call stack ← event loop ← callback queue
↑ ↑
your code Web APIs / libuv
(timers, network, I/O)So the language is single threaded, but the runtime around it is not. That is the whole trick.
The Ordering That Confuses Everyone
Start with the classic example.
console.log('1');
setTimeout(() => console.log('2'), 0);
console.log('3');
// Output: 1, 3, 2Even with a zero delay, 2 comes last. The timeout callback cannot run until the call stack is empty, and the stack does not empty until the current script finishes. Zero milliseconds means "as soon as possible after the current work", not "now".
Now the version that separates people who have read about the event loop from people who understand it:
console.log('start');
setTimeout(() => console.log('timeout'), 0);
Promise.resolve().then(() => console.log('promise'));
console.log('end');
// Output: start, end, promise, timeoutThe promise callback beats the timeout despite being written after it. That is not a race — it is guaranteed, and it comes from there being two queues rather than one.
Microtasks Beat Macrotasks, Always
There are two queues with different priorities.
Macrotasks (the task queue): setTimeout, setInterval, I/O callbacks, UI events.
Microtasks: promise callbacks (.then, .catch, .finally), await continuations, queueMicrotask, MutationObserver.
The rule is strict: after each macrotask, the event loop drains the entire microtask queue before taking another macrotask. Not one microtask — all of them, including any added while draining.
setTimeout(() => console.log('macro 1'), 0);
setTimeout(() => console.log('macro 2'), 0);
Promise.resolve().then(() => {
console.log('micro 1');
Promise.resolve().then(() => console.log('micro 2')); // added during draining
});
// Output: micro 1, micro 2, macro 1, macro 2Both microtasks run before either timeout, because the queue is drained to empty.
This has a sharp edge: a microtask that keeps scheduling microtasks starves the macrotask queue permanently. Timers never fire, rendering never happens, the page locks up. An infinite loop of promises is just as fatal as a while (true), and considerably less obvious.
Where async and await Fit
await is promise syntax, so it follows the microtask rules. Everything after an await is effectively the body of a .then.
async function run() {
console.log('A');
await null; // yields here, even though nothing is pending
console.log('B'); // scheduled as a microtask
}
run();
console.log('C');
// Output: A, C, BThe function runs synchronously up to the first await, then returns control. The rest is queued as a microtask. This is why B appears after C even though nothing was actually asynchronous.
The practical consequence is sequential awaits in a loop:
// Sequential — each waits for the previous. Three seconds for three calls.
for (const url of urls) {
await fetch(url);
}
// Parallel — all start immediately, then wait for all. About one second.
await Promise.all(urls.map(u => fetch(u)));Both are correct code; they differ enormously in speed. Use the sequential form only when each call genuinely depends on the previous result.
Also worth knowing: forEach ignores async callbacks entirely. It does not await them, so the loop finishes before any of the work does. Use a for...of loop or Promise.all.
What Blocking Actually Means
Because there is one thread, any long synchronous operation stops everything — no clicks, no rendering, no timers.
// Freezes the page completely for the duration
function heavy() {
let total = 0;
for (let i = 0; i < 1e9; i++) total += i;
return total;
}In a browser this is the "page unresponsive" dialog. In Node it is worse in a quieter way: one blocked request stalls every connected client, because they all share the thread.
Common culprits are large JSON.parse calls, synchronous file operations like fs.readFileSync, heavy loops over big arrays, and expensive regular expressions.
The fixes depend on where you are. In the browser, move genuinely heavy computation to a Web Worker, which runs on a separate thread. In Node, use the asynchronous form of I/O and consider worker_threads for CPU-bound work. Where the work can be split, breaking it into chunks separated by setTimeout lets the loop breathe between pieces.
The mental model worth keeping: asynchronous does not mean parallel. Async work is still executed on the same single thread — it is only waiting that happens elsewhere. Ten seconds of computation is ten seconds of frozen page whether or not it sits inside an async function.
