JavaScript is single-threaded, yet it somehow manages timers, network requests, and UI updates without freezing your app. The event loop is the mechanism that makes this possible.
This article walks from beginner to intermediate: call stack, Web APIs, task queues, microtasks (Promises), and how the browser/Node.js actually schedule your code.
JavaScript is single-threaded
JavaScript has one main thread of execution in the browser: at any instant it runs exactly one piece of JS code, in a single sequence. This code runs in an environment with additional features provided by the browser or Node.js, such as timers, networking, and DOM APIs.
Because only one thing can execute at a time, heavy synchronous code (like long loops or expensive computations) can block the UI and stop it from responding. The event loop exists to coordinate synchronous and asynchronous work so that the main thread stays as responsive as possible.
The call stack: where code actually runs
The call stack is a stack (LIFO) data structure the JS engine uses to track which function is currently executing.
- When you call a function, a new frame is pushed onto the stack.
- When the function returns, its frame is popped off.
- JavaScript always executes the top frame on the stack.
Example:
function c() {
console.log("c");
}
function b() {
c();
console.log("b");
}
function a() {
b();
console.log("a");
}
a();
console.log("done");
Execution flow:
a()is called →ais pushed.acallsb()→bis pushed.bcallsc()→cis pushed.clogs"c"and returns →cis popped.blogs"b"and returns →bis popped.alogs"a"and returns →ais popped.- Global code logs
"done".
Output:
c
b
a
done
Purely synchronous code follows this simple “top of the stack first” behavior.
Why we need asynchronous behavior
Real applications cannot afford to block the main thread while waiting for:
- Network responses (
fetch,XMLHttpRequest) - Timers (
setTimeout,setInterval) - Disk or database operations (Node.js)
- DOM events like clicks or scrolls
If JavaScript itself waited inside the call stack for each of these to finish, the UI would freeze and the app would feel broken.
Instead, these tasks are delegated to environment APIs:
- In browsers: Web APIs (timers, network, DOM events, etc.).
- In Node.js: C++ bindings and libuv for timers, I/O, etc.
Those APIs handle the heavy work and, when they are done, they send back callbacks to be processed later by the event loop.
The event loop at a high level
Conceptually, the event loop keeps doing this:
- Check the call stack.
- If it is not empty, keep executing the top frame.
- If the stack is empty:
- Process all microtasks (we will define them shortly).
- Then take the next macrotask from the task queue and push its callback onto the stack.
- Repeat forever while the page or process is alive.
This loop is what connects:
- Your code (on the call stack)
- The environment (Web APIs or Node APIs)
- The queues (where callbacks wait)
Web APIs and task queues
When you call an asynchronous API, JavaScript does not keep that operation on the call stack. Instead:
- JavaScript registers a callback with the relevant environment API.
- The call stack frame returns and clears as usual.
- The environment does its work in the background.
- When it finishes, it queues the callback into a task queue.
The event loop will later move that callback from the task queue to the call stack when the stack is empty.
Example:
console.log("Start");
setTimeout(() => {
console.log("Timeout callback");
}, 0);
console.log("End");
Step-by-step:
"Start"is logged synchronously.setTimeoutregisters the callback with the timer API and returns immediately."End"is logged synchronously.- After the current stack finishes, the event loop eventually pulls the timeout callback from the task queue and executes it.
Output:
Start
End
Timeout callback
Even with a delay of 0, the callback is never allowed to run before the current synchronous code finishes.
Macrotasks vs microtasks
Modern JavaScript runtimes distinguish between two main categories of tasks: macrotasks and microtasks.
Macrotasks (task queue)
Macrotasks include:
setTimeoutsetInterval- Some types of I/O callbacks
- MessageChannel messages
- UI or DOM events (like click handlers)
The event loop takes one macrotask at a time from the task queue, runs it to completion (synchronously on the call stack), then checks microtasks, then moves on.
Microtasks (microtask queue)
Microtasks are scheduled for “as soon as possible after the current code finishes” and before the next macrotask.
Common sources:
Promisecallbacks (.then,.catch,.finally)queueMicrotaskMutationObserverin the browser
After every macrotask finishes, the event loop will run all microtasks in the microtask queue before continuing.
This priority is crucial to understanding log order.
Classic example: Promise vs setTimeout
Consider:
console.log("A");
setTimeout(() => {
console.log("B");
}, 0);
Promise.resolve().then(() => {
console.log("C");
});
console.log("D");
What is the output?
"A"logs (synchronous).setTimeoutschedules a macrotask.Promise.resolve().then(...)schedules a microtask."D"logs (synchronous).- The call stack is now empty.
- Event loop checks microtask queue:
- Runs the Promise’s
.thencallback → logs"C".
- Runs the Promise’s
- Microtask queue is empty, event loop takes the next macrotask:
- Runs
setTimeoutcallback → logs"B".
- Runs
Output:
A
D
C
B
The Promise callback runs before the setTimeout callback because microtasks have higher priority than macrotasks.
Visual mental model
A simplified mental model for the browser looks like this:
- Call Stack: Where JS runs synchronously.
- Web APIs: Timers, network, DOM, etc. that handle async work.
- Task Queue (Macrotasks): Callbacks from timers, I/O, events.
- Microtask Queue: Promise callbacks and other microtasks.
- Event Loop:
- If stack is empty → run all microtasks.
- Then take the next macrotask → push its callback on the stack.
In pseudo-steps:
while (true) {
if (callStack not empty) continue;
// Run microtasks first
while (microtaskQueue not empty) {
callback = microtaskQueue.dequeue();
callStack.push(callback);
// callback runs and returns
}
// Then a macrotask
if (taskQueue not empty) {
callback = taskQueue.dequeue();
callStack.push(callback);
// callback runs and returns
}
// Render, paint, etc. can happen between iterations
}
The real implementation is more nuanced, but this model explains almost all timing behavior you encounter day to day.
Browser vs Node.js event loop
Both browsers and Node.js use an event loop, but Node.js has a more detailed phase-based loop for server workloads.
According to the Node.js docs, each loop iteration goes through several phases:
- Timers: Executes callbacks scheduled by
setTimeoutandsetInterval. - Pending callbacks: Executes I/O callbacks deferred from the previous cycle.
- Idle, prepare: Internal use.
- Poll: Retrieves new I/O events; may block here when appropriate.
- Check: Executes
setImmediatecallbacks. - Close callbacks: Runs close event callbacks (like
socket.on('close', ...)).
Node also has two microtask mechanisms:
process.nextTickqueue- Promise microtask queue
process.nextTick callbacks run before Promise microtasks, and both run between phases. This makes Node timing slightly different from the browser, but the same core concepts still apply: call stack, task queues, microtasks, repeat.
Common pitfalls and how to avoid them
1. Blocking the main thread
Large loops or heavy computations keep the call stack busy and prevent the event loop from processing tasks.
Bad:
button.addEventListener("click", () => {
// Simulate heavy work
for (let i = 0; i < 1e9; i++) {}
console.log("Done");
});
The UI will freeze until this finishes. Consider:
- Splitting work into smaller chunks using
setTimeoutorqueueMicrotask. - Offloading work to Web Workers when possible.
2. Assuming setTimeout(fn, 0) runs immediately
setTimeout(fn, 0) just means “run later, in a separate macrotask when the stack is clear”.
If there are microtasks or other tasks queued, your callback may run noticeably later.
3. Misunderstanding Promise order
Because microtasks run before macrotasks, Promise chains often run “earlier” than expected. Always remember:
- Synchronous code
- Then microtasks
- Then macrotasks
How to get comfortable with the event loop
To internalize this, try:
- Writing small snippets and predicting log order before running them.
- Using devtools:
- Add
console.logwith labels like"sync","microtask","timeout"and inspect order.
- Add
- Watching an event loop visualization or using online visual tools that step through the stack and queues.
Over time, the event loop becomes less “magic” and more a predictable scheduling system you can rely on.
Summary
The event loop is the core of JavaScript’s execution model:
- JavaScript uses a single-threaded call stack for synchronous code.
- Asynchronous operations are handled by environment APIs and their callbacks are queued.
- The event loop coordinates:
- The call stack
- The microtask queue (Promises, etc.)
- The macrotask queue (timers, I/O, events)
- Microtasks run before the next macrotask, which explains why Promise callbacks often run before
setTimeout. - Browsers and Node.js both use this model, with Node.js adding more phases and
process.nextTick.
Once you understand this flow, debugging “weird” execution order becomes much easier, and you can design smoother, non-blocking JavaScript applications with confidence.