Understanding the JavaScript Event Loop
The event loop is the heartbeat of asynchronous JavaScript. It determines when your callbacks run, when promises resolve, and how your code executes in sequence despite JavaScript being single-threaded.
What Is the Event Loop?
JavaScript runs on a single thread. It can only do one thing at a time. Yet you can write code that feels asynchronous—handling user clicks while downloading files, updating UI while fetching data.
This works because JavaScript separates the execution (what happens on the thread) from the scheduling (when things happen).
The event loop constantly checks if the call stack is empty. If it is, it takes the next task from a queue and runs it. That queue is the bridge between asynchronous operations and your code.
The Three Key Concepts
The Call Stack
The call stack is where JavaScript tracks function execution. When you call a function, it gets pushed onto the stack. When it returns, it pops off.
function greet() {
console.log("Hello");
}
function sayHi() {
greet();
}
sayHi();
This pushes sayHi, then greet, then logs, then each pops off in reverse order. The stack processes synchronously—one function must finish before the next begins.
Task Queue (Macrotasks)
The task queue holds callbacks from setTimeout, setInterval, I/O operations, and UI rendering. When the call stack empties, the event loop takes the first task from this queue and runs it.
console.log("Start");
setTimeout(() => {
console.log("Timeout callback");
}, 0);
console.log("End");
Output:
Start
End
Timeout callback
Even with a 0ms delay, the callback runs after the synchronous code finishes. The timeout goes off, but its callback waits in the task queue until the stack clears.
Microtask Queue
Promises, queueMicrotask, and mutation observers go into the microtask queue. This queue has priority over the task queue.
console.log("Start");
Promise.resolve().then(() => {
console.log("Promise resolved");
});
setTimeout(() => {
console.log("Timeout");
}, 0);
console.log("End");
Output:
Start
End
Promise resolved
Timeout
The promise callback runs before the timeout callback because microtasks always execute after the current synchronous code but before any new task queue tasks.
The Order of Execution
Here is the complete sequence:
- Run all synchronous code until the stack is empty
- Run all microtasks until the queue is empty
- Render any UI updates
- Take the first task from the task queue and run it
- Repeat
console.log("1. Synchronous");
setTimeout(() => console.log("2. Task queue"), 0);
Promise.resolve().then(() => console.log("3. Microtask"));
console.log("4. More sync");
Output:
// 1. Synchronous
// 4. More sync
// 3. Microtask
// 2. Task queue
Practical Example
Understanding this order matters when mixing timers and promises:
function demo() {
console.log("1. Start");
setTimeout(() => console.log("2. Timeout 1"), 0);
Promise.resolve()
.then(() => {
console.log("3. Promise 1");
return Promise.resolve();
})
.then(() => console.log("4. Promise 2"));
setTimeout(() => console.log("5. Timeout 2"), 0);
console.log("6. End");
}
demo();
Output order:
// 1. Start
// 6. End
// 3. Promise 1
// 4. Promise 2
// 2. Timeout 1
// 5. Timeout 2
Common Misconceptions
”setTimeout with 0ms runs immediately”
It doesn’t. It schedules the callback for the next iteration of the event loop, after all synchronous code and all microtasks complete.
”Promises run in parallel”
They don’t. A promise callback runs asynchronously but one at a time. The microtask queue processes sequentially.
”async/await creates new threads”
It doesn’t. async/await is syntactic sugar over promises. The function pauses at await, yields to the event loop, and resumes when the promise resolves.
async function example() {
console.log("1. Start");
await Promise.resolve();
console.log("2. After await");
console.log("3. Also after await");
}
The two lines after await are both microtasks. They run in order before any task queue callbacks.
Browser vs Node.js
The event loop works similarly in browsers and Node.js, but there are differences.
In browsers, rendering happens between task queue callbacks. This is why long-running JavaScript blocks the UI—the browser can’t render until your code yields.
Node.js doesn’t have a built-in renderer, so it can process tasks more continuously. However, libuv (the underlying library) handles async I/O through a thread pool for file system operations.
queueMicrotask
You can add your own callbacks to the microtask queue using queueMicrotask:
queueMicrotask(() => {
console.log("This runs as a microtask");
});
This is useful when you need your callback to run before the browser renders but after synchronous code completes.
Why This Matters
If you don’t understand the event loop, you might accidentally block the UI:
// BAD: Blocks the thread
function processLargeArray(arr) {
for (let item of arr) {
heavyComputation(item);
}
}
// GOOD: Yields to the event loop
async function processLargeArray(arr) {
for (let item of arr) {
heavyComputation(item);
await Promise.resolve(); // Yield to UI
}
}
The second version lets the browser render, handle clicks, and process other callbacks between iterations.
See Also
- Callbacks, Promises, and async/await — Learn the evolution of async patterns in JavaScript
- Promise — Understand the Promise object
- JavaScript Closures Explained — Understand scope, which pairs with event loop knowledge