Here's a classic JavaScript puzzle. What order do these log?
If you said A → D → C → B, you understand the event loop. If that surprised you, this article is for you.
JavaScript Is Single-Threaded
Despite handling network requests, timers, and user events simultaneously, JavaScript runs on a single thread. It can only execute one piece of code at a time. The event loop is the system that coordinates everything - deciding what runs when.
To understand it, you need to know three structures: the call stack, the task queue (also called the macrotask queue), and the microtask queue.
The Call Stack
The call stack is where JavaScript tracks which function is currently running. When you call a function, it gets pushed onto the stack. When it returns, it's popped off.
JavaScript can only process the next piece of work when the call stack is empty. This is why a long synchronous operation freezes the browser - nothing else can run while the stack has frames on it.
The Task Queue (Macrotasks)
When an asynchronous operation completes - a timer fires, a network response arrives, a click event happens - its callback isn't pushed onto the call stack immediately. It's placed into the task queue and waits.
The event loop checks the task queue only when the call stack is completely empty. It picks one task, runs it to completion, and then checks again.
- › setTimeout / setInterval callbacks
- › DOM events (click, keydown, etc.)
- › MessageChannel callbacks
- › I/O callbacks in Node.js
This is why setTimeout(fn, 0) doesn't mean "run immediately." It means "run after the current call stack clears and all microtasks are done." Even a 0ms timer can be delayed.
The Microtask Queue
Microtasks are a higher-priority queue. After each task completes (and after the call stack empties), the engine drains the entire microtask queue before moving on to the next macrotask.
- › Promise .then() / .catch() / .finally() callbacks
- › async/await continuations (they compile to Promises under the hood)
- › queueMicrotask() callbacks
- › MutationObserver callbacks
The Full Loop - Step by Step
Here's exactly what the event loop does on each cycle:
- › Execute the current synchronous code until the call stack is empty
- › Drain the entire microtask queue (run all pending microtasks, including any new ones queued during this step)
- › Pick one task from the macrotask queue and run it
- › Drain the microtask queue again
- › Repeat
Now the original puzzle makes sense:
Practical Implications
async/await and the loop
Every await suspends the current function and schedules the rest as a microtask. This keeps long async chains from blocking, but their callbacks still don't run until the current synchronous block finishes.
Avoid blocking the event loop
Any synchronous work that takes more than a few milliseconds will freeze your UI. Common culprits: sorting huge arrays, parsing large JSON, or deeply recursive operations on the main thread.
Mental Model Summary
The call stack runs synchronous code. Microtasks (Promises) cut the queue and run before the next macrotask. Macrotasks (timers, events) wait their turn. The event loop keeps cycling until there's nothing left.
- Synchronous code always runs first, in order
- Promises resolve in the microtask queue - before any timer callback
- setTimeout(fn, 0) is not zero delay - it waits for the stack and microtasks to clear
- Heavy sync work blocks everything - use Workers for CPU-intensive tasks
- async/await is syntactic sugar over Promises - same queue, same rules