Event Loop in JavaScript: How Async Really Works
Source: Dev.to – Event Loop in JavaScript: How Async Really Works
The Event Loop – How Asynchronous JavaScript Really Works
JavaScript is single‑threaded: there is only one call stack and one thread executing your code at any given moment. Yet you can make HTTP requests, set timers, handle user clicks, and your code doesn’t freeze while waiting for those operations.
How? The Event Loop.
Most explanations show a diagram with boxes and arrows. Below we dive deeper and look at what actually happens when your async code runs.
The Mental Model: JavaScript Runtime Architecture
JavaScript never runs in isolation. The runtime (browser or Node.js) consists of four main parts:
-
The JavaScript Engine (V8, SpiderMonkey, etc.)
- Call Stack – where synchronous code executes.
- Heap – where objects live.
-
Web APIs / C++ APIs (provided by the browser or Node.js)
- Timer APIs (
setTimeout,setInterval) - Network APIs (
fetch,XMLHttpRequest) - File‑system, crypto, etc. (Node.js)
- Timer APIs (
-
Task Queues
- Macrotask Queue (also called Task Queue or Callback Queue)
- Microtask Queue
-
The Event Loop – coordinates everything.
Key point: When you call
setTimeout, you’re not invoking a JavaScript function; you’re invoking a Web API that runs outside the JavaScript engine. The event loop bridges the gap between the engine and these external APIs.
The Event Loop: What It Actually Does
Conceptually, the event loop is an infinite loop that repeatedly does the following:
while (true) {
if (callStack.isEmpty()) {
if (microtaskQueue.hasItems()) {
microtaskQueue.executeNext(); // run one microtask
} else if (macrotaskQueue.hasItems()) {
macrotaskQueue.executeNext(); // run one macrotask
}
}
}
In words: “If the call stack is empty, first drain the microtask queue; if that’s empty, take the next macrotask.”
A Real‑World Example
console.log('Start');
setTimeout(() => {
console.log('Timeout');
}, 0);
Promise.resolve().then(() => {
console.log('Promise');
});
console.log('End');
Expected output
Start
End
Promise
Timeout
Let’s see why.
Phase‑by‑Phase Execution
Phase 1 – Synchronous code runs
console.log('Start'); // prints "Start"
Call stack: [console.log] → executes → pops
Phase 2 – setTimeout
setTimeout(() => {
console.log('Timeout');
}, 0);
setTimeoutis pushed onto the call stack.- The browser’s timer API is invoked (runs on a separate thread).
setTimeoutreturns immediately and is popped off.- The timer starts a 0 ms countdown. When it expires, the callback is queued in the macrotask queue.
Call stack: [setTimeout] → pops
Macrotask queue: [] (callback will appear after ~0 ms)
Phase 3 – Promise
Promise.resolve().then(() => {
console.log('Promise');
});
Promise.resolve()creates an already‑resolved promise..then()schedules its callback in the microtask queue.
Call stack: [Promise.resolve, .then] → pops
Microtask queue: [() => console.log('Promise')]
Phase 4 – More synchronous code
console.log('End'); // prints "End"
Call stack: [console.log] → executes → pops
Phase 5 – Call stack empty → Event loop runs
- Microtasks first – the
Promisecallback is moved to the call stack, runs, prints “Promise”, and clears the microtask queue. - Then macrotasks – the timer callback is taken from the macrotask queue, runs, and prints “Timeout”.
Final output:
Start
End
Promise
Timeout
Microtasks vs. Macrotasks: The Priority System
The event loop always processes all microtasks before moving on to the next macrotask.
| Macrotasks (Task Queue) | Microtasks |
|---|---|
setTimeout | Promise.then / catch / finally |
setInterval | queueMicrotask() |
setImmediate (Node only) | MutationObserver (browser) |
| I/O callbacks (Node) | process.nextTick (Node – even higher priority) |
| UI rendering (browser) | — |
Priority example
setTimeout(() => console.log('Timeout 1'), 0);
Promise.resolve().then(() => {
console.log('Promise 1');
Promise.resolve().then(() => console.log('Promise 2'));
});
setTimeout(() => console.log('Timeout 2'), 0);
Promise.resolve().then(() => console.log('Promise 3'));
Expected output
Promise 1
Promise 3
Promise 2
Timeout 1
Timeout 2
Why this order?
- Synchronous code runs first (nothing is logged yet).
- The call stack empties, so the event loop drains the microtask queue:
Promise 1runs and queuesPromise 2.Promise 3runs.Promise 2runs (the microtask queue must be empty before any macrotask).
- Only after the microtask queue is empty does the loop process macrotasks:
Timeout 1thenTimeout 2.
Node.js Event Loop – More Complex Phases
Node.js splits the event loop into a series of phases that run in a fixed order on every tick:
┌─────────────────────────────┐
│ timers │ ← setTimeout, setInterval
├─────────────────────────────┤
│ pending callbacks │ ← I/O callbacks deferred from previous tick
├─────────────────────────────┤
│ idle, prepare │ ← internal use only
├─────────────────────────────┤
│ poll │ ← retrieve new I/O events; execute I/O callbacks
├─────────────────────────────┤
│ check │ ← setImmediate callbacks
├─────────────────────────────┤
│ close callbacks │ ← e.g., socket.on('close')
└─────────────────────────────┘
Each phase has its own queue(s). After a phase finishes, Node checks the microtask queue (promises, process.nextTick) before moving to the next phase.
This fine‑grained ordering explains why setImmediate and process.nextTick behave differently from their browser counterparts.
TL;DR
| Concept | What it does |
|---|---|
| Call stack | Executes synchronous code. |
| Web APIs / C++ APIs | Perform work off the main thread (e.g., I/O, timers). |
| Task queues | Hold callbacks (macrotasks) and microtasks. |
| Event loop | When the stack is empty, it drains the microtask queue first, then a macrotask, and repeats. |
Understanding this flow lets you reason about ordering, performance, and potential pitfalls in both browser and Node.js environments.
Node.js Event Loop – Visual Guide
┌───────────────────────────────────────┐
│ timers │ ← setTimeout / setInterval
├───────────────────────────────────────┤
│ pending callbacks │ ← I/O callbacks from previous tick
├───────────────────────────────────────┤
│ idle, prepare │ ← internal use only
├───────────────────────────────────────┤
│ poll │ ← retrieve new I/O events; execute I/O callbacks
├───────────────────────────────────────┤
│ check │ ← setImmediate callbacks
├───────────────────────────────────────┤
│ close callbacks │ ← e.g., socket.on('close')
└───────────────────────────────────────┘
Between each phase: All microtasks (promises,
process.nextTick) are processed before the next phase begins.
Use this diagram as a quick reference when debugging timing‑related issues or when deciding whether to use setTimeout, setImmediate, or process.nextTick in your Node.js code.
Timers Phase
The timers phase executes callbacks scheduled by setTimeout and setInterval whose timers have expired.
Important: The timer specifies the minimum delay, not a guarantee.
setTimeout(() => console.log('100ms'), 100);
If the event loop is busy (e.g., executing a long‑running task), the callback may run after 150 ms, 200 ms, etc.
Poll Phase
This is where Node.js spends most of its time.
- Executes I/O callbacks (file reads, network requests, …).
- If there are no callbacks, it waits for new events (blocking, but can be interrupted).
When an I/O operation finishes (e.g., a file read or an HTTP response), its callback is placed in the poll queue.
Check Phase – setImmediate
setImmediate behaves like setTimeout(fn, 0), but it always runs after the poll phase.
const fs = require('fs');
fs.readFile('file.txt', () => {
setTimeout(() => console.log('setTimeout'), 0);
setImmediate(() => console.log('setImmediate'));
});
Output
setImmediate
setTimeout
Why?
When the callback is executed inside an I/O operation (the poll phase), setImmediate is scheduled for the next check phase, whereas setTimeout must wait for the next timers phase (the following loop cycle).
process.nextTick – The Priority Microtask
In Node.js, process.nextTick has higher priority than regular microtasks.
Promise.resolve().then(() => console.log('Promise'));
process.nextTick(() => console.log('nextTick'));
Output
nextTick
Promise
Execution order in Node.js
- Synchronous code
process.nextTickqueue – drained completely- Microtask queue – drained completely
- Next event‑loop phase
Warning: You can starve the event loop.
function recurse() { process.nextTick(recurse); } recurse(); // Blocks the loop foreverThe
nextTickqueue never empties, so I/O callbacks never run.
I/O Operations – How They Actually Work
const fs = require('fs');
console.log('Start');
fs.readFile('data.txt', (err, data) => {
console.log('File read');
});
console.log('End');
What Happens
fs.readFileis called – the synchronous part of the function runs.- Node.js hands the work off to libuv (the C++ layer).
- libuv uses the operating system’s async I/O facilities or a thread pool.
fs.readFilereturns immediately (non‑blocking).- JavaScript continues executing the next line (
console.log('End')). - When the file read completes, libuv notifies the event loop.
- The callback is placed in the poll queue.
- The event loop picks the callback from the queue and executes it.
Output
Start
End
File read
The I/O itself runs in parallel (on a separate thread or via OS mechanisms), but the callback is always executed on the main JavaScript thread.
Practical Example – Ordering Chaos
setTimeout(() => console.log('1'), 0);
setImmediate(() => console.log('2'));
Promise.resolve().then(() => console.log('3'));
process.nextTick(() => console.log('4'));
console.log('5');
Output
5
4
3
1
2
Execution order
| Step | What runs | Reason |
|---|---|---|
| 1 | 5 | Synchronous code |
| 2 | 4 | process.nextTick queue |
| 3 | 3 | Microtask queue (Promise) |
| 4 | 1 | Macrotask – timers phase |
| 5 | 2 | Check phase (setImmediate) |
Note: Outside an I/O callback, the relative order of
setTimeoutvs.setImmediatecan vary.
Visualizing Async Flow
console.log('A');
setTimeout(() => {
console.log('B');
Promise.resolve().then(() => console.log('C'));
}, 0);
Promise.resolve()
.then(() => console.log('D'))
.then(() => console.log('E'));
console.log('F');
Execution trace
| Phase | Output |
|---|---|
| Sync | A, F |
| Microtasks | D, E |
| Macrotask (timer) | B |
| Microtasks (after timer) | C |
Final output: A F D E B C
Common Pitfalls
1. Assuming setTimeout(fn, 0) runs immediately
let x = 0;
setTimeout(() => { x = 1; }, 0);
console.log(x); // 0, not 1!
The callback runs after all synchronous code finishes.
2. Infinite microtask loops
function loop() {
Promise.resolve().then(loop);
}
loop(); // Blocks the event loop!
Each promise schedules another microtask, starving macrotasks.
3. Heavy computation blocking the loop
setTimeout(() => console.log('Never runs?'), 100);
// Block for 5 seconds
const start = Date.now();
while (Date.now() - start < 5000) {}
The setTimeout callback won’t fire until the blocking loop ends.
Takeaway: Understanding the exact order of synchronous code, process.nextTick, microtasks, and the various macrotask phases (timers, poll, check, close) is essential for writing correct, performant Node.js applications.
Summary
The event loop is JavaScript’s solution to single‑threaded asynchronous execution.
Key Points
- Synchronous code runs first.
- Async APIs (timers, I/O, etc.) execute outside the JavaScript engine and notify it when they finish.
- Callbacks are placed in queues:
- Microtasks – e.g.,
Promisecallbacks,process.nextTick. - Macrotasks – e.g.,
setTimeout, I/O callbacks.
- Microtasks – e.g.,
- The event loop processes all microtasks before any macrotask.
- In Node.js the loop consists of distinct phases (
timers → poll → check), with microtasks running between phases. process.nextTickhas the highest priority (use it sparingly).
Mental Model
- Run all synchronous code to completion.
- Drain the microtask queue.
- Pick one macrotask and execute it.
- Drain the microtask queue again.
- Repeat from step 2.
Why It Matters
- Predict execution order of
setTimeout,Promise, etc. - Avoid blocking the event loop with long‑running sync code.
- Debug async race conditions more easily.
- Write performant async code by leveraging the right queue.
Next time you see a
setTimeoutor aPromise, you’ll know exactly when that code will run – and why.