Understanding the JavaScript Event Loop
2026-08-04 · 4 min read
JavaScript is single-threaded — it can only execute one piece of code at a time. Yet it handles asynchronous operations like network requests, timers, and I/O without blocking. The mechanism that makes this possible is the event loop.
The Building Blocks
The Call Stack
The call stack is a LIFO data structure that tracks function execution. When you call a function, it's pushed onto the stack. When it returns, it's popped off.
function greet(name) {
return `Hello, ${name}`
}
function main() {
const message = greet('Paul')
console.log(message)
}
main()
Execution order: main is pushed, then greet, then greet returns and is popped, then console.log runs and is popped, then main returns and is popped.
Web APIs / libuv
The JS runtime itself has no concept of timers or HTTP. These are provided by the host environment. When you call setTimeout or fetch, you're handing work off to the browser's Web APIs (or Node's libuv thread pool). The JS thread is free to continue while this work happens elsewhere.
The Task Queue (Macrotask Queue)
When a Web API finishes, it places its callback in the task queue. Callbacks here include:
setTimeout/setIntervalcallbacks- DOM events (click, keypress)
MessageChannelmessages
The Microtask Queue
The microtask queue has higher priority than the task queue. It drains completely after every task, before the event loop picks up the next one. It includes:
- Promise
.then/.catch/.finallycallbacks queueMicrotask()MutationObservercallbacks
The Event Loop
The event loop's job: if the call stack is empty, drain the microtask queue fully, then run the next task, and repeat.
// Pseudocode
while (true) {
drainMicrotaskQueue()
runNextTask()
}
Putting It Together
console.log('1')
setTimeout(() => console.log('2'), 0)
Promise.resolve().then(() => console.log('3'))
console.log('4')
Output: 1, 4, 3, 2
Here's why:
console.log('1')— synchronous, runs immediatelysetTimeout— callback handed to Web API, will land in task queue after 0msPromise.resolve().then(...)— microtask queuedconsole.log('4')— synchronous, runs immediately- Call stack empty → drain microtask queue →
3logs - Event loop picks next task →
2logs
Even though setTimeout(..., 0) was registered before the promise, the microtask queue always drains first.
A Trickier Example
Promise.resolve()
.then(() => {
console.log('microtask 1')
return Promise.resolve()
})
.then(() => console.log('microtask 2'))
setTimeout(() => console.log('task 1'), 0)
Output: microtask 1, microtask 2, task 1
Each .then schedules a new microtask. Since the queue drains completely before any task runs, both microtasks finish before task 1 gets a turn.
async/await Under the Hood
async/await is syntactic sugar over promises. await suspends the async function and schedules the continuation as a microtask when the awaited value resolves.
async function fetchData() {
console.log('before await')
const data = await Promise.resolve(42)
console.log('after await', data)
}
fetchData()
console.log('synchronous')
Output: before await, synchronous, after await 42
The code after await behaves like a .then callback — it goes into the microtask queue and runs only after the current synchronous code finishes.
Blocking the Event Loop
Any long-running synchronous code blocks everything else — UI updates, incoming requests, timers — because there's only one thread.
function blockFor(ms) {
const start = Date.now()
while (Date.now() - start < ms) {}
}
blockFor(5000) // nothing else runs for 5 seconds
For CPU-intensive work, offload to a Web Worker (browser) or worker_threads (Node.js).
Summary
| Contents | Runs | |
|---|---|---|
| Call Stack | Currently executing code | Immediately |
| Microtask Queue | Promise callbacks, queueMicrotask | After current task, fully |
| Task Queue | setTimeout, events, I/O | One per event loop tick |
Understanding this model explains a lot of subtle async behavior: why promise chains resolve before timers, why await doesn't actually block, and why long synchronous work is dangerous on a single thread.