Demystifying the JavaScript Event Loop and Asynchronous Patterns
Introduction
JavaScript is a single-threaded language. This is one of the first facts you learn when picking up the language. You have one call stack, one thread of execution, and it can only do one thing at a time. Yet, we build complex web applications that fetch data from servers, listen for user input, and animate UI elements all simultaneously without freezing the browser.
How is this possible? The answer lies in the JavaScript Event Loop. Understanding how this mechanism works is what separates junior developers who randomly scatter async and await throughout their codebase from senior engineers who can accurately predict the execution order of their asynchronous logic.
In this deep dive, we are going to tear apart the browser’s execution model. We’ll explore the Call Stack, the Web APIs, the Callback Queue, and the Microtask Queue. By the end, you’ll be able to trace complex asynchronous code in your head and avoid common performance bottlenecks.
Core Concepts: The V8 Engine and the Browser Environment
First, we need to make a critical distinction: the Event Loop is not part of the JavaScript engine itself (like Google’s V8 engine). The engine only has two main components: the Memory Heap (where objects are allocated) and the Call Stack (where your code is executed).
The engine doesn’t know about setTimeout, the DOM, or HTTP requests. These are features provided by the runtime environment—in most cases, the web browser or Node.js. The browser provides Web APIs (like the Fetch API, DOM API, and Timer API), and the Event Loop is the mechanism that coordinates the interaction between these Web APIs and the engine’s Call Stack.
The Anatomy of the Event Loop
To understand the execution flow, you have to picture four distinct areas in the architecture.
1. The Call Stack
This is where your synchronous code lives. When a function is called, it gets pushed onto the stack. When the function returns, it is popped off. Because it’s a stack, it operates on a Last-In-First-Out (LIFO) basis.
2. The Web APIs
When you call setTimeout or fetch, the Call Stack passes the operation off to the browser’s Web APIs. The browser takes over the work in a separate background thread (written in C++ or Rust), completely outside of the JavaScript engine. The Call Stack is immediately freed up to execute the next line of code.
3. The Callback Queue (Macrotask Queue)
Once a Web API finishes its background task (e.g., the timer expires or the HTTP request completes), it takes the callback function associated with that task and pushes it into the Callback Queue. This queue operates on a First-In-First-Out (FIFO) basis.
4. The Event Loop
The Event Loop has one very simple, continuous job: it looks at the Call Stack and looks at the Queues. If the Call Stack is empty, it takes the first callback in the queue and pushes it onto the Call Stack to be executed. If the Call Stack is not empty, the Event Loop waits.
Microtasks vs. Macrotasks: The Secret Hierarchy
Things get complicated when Promises enter the picture. JavaScript actually has two queues: the Macrotask Queue (often just called the Callback Queue) and the Microtask Queue.
- Macrotasks:
setTimeout,setInterval,setImmediate(Node.js), I/O operations, UI rendering. - Microtasks:
Promisecallbacks (.then,.catch,.finally),MutationObserver,queueMicrotask.
The rule is simple but profound: The Event Loop prioritizes the Microtask Queue. Before moving on to the next Macrotask, the Event Loop will entirely empty the Microtask Queue. If a microtask queues up another microtask, the Event Loop will execute that one too, potentially starving the Macrotask Queue forever.
Tracing the Execution Order (Code Example)
Let’s look at a classic interview question. Try to predict the exact output order before reading the explanation.
console.log("1: Script start");
setTimeout(() => {
console.log("2: setTimeout 1 (Macrotask)");
}, 0);
Promise.resolve()
.then(() => {
console.log("3: Promise 1 (Microtask)");
})
.then(() => {
console.log("4: Promise 2 (Microtask)");
});
setTimeout(() => {
console.log("5: setTimeout 2 (Macrotask)");
}, 0);
console.log("6: Script end");
Here is exactly what happens step-by-step:
- The script starts.
console.log(“1: Script start”)goes on the Call Stack and executes. setTimeoutis called. The Web API sets a timer for 0ms. It finishes instantly and pushes its callback to the Macrotask Queue.Promise.resolve().then(…)is called. The promise resolves instantly, pushing its callback to the Microtask Queue.- The second
setTimeoutis called. It goes to the Web API and its callback is pushed to the Macrotask Queue. console.log(“6: Script end”)goes on the Call Stack and executes.- The synchronous code is done. The Call Stack is empty.
- The Event Loop checks the Microtask Queue first. It finds the first Promise callback, pushes it to the stack, and logs “3: Promise 1”.
- This first promise returns another promise, pushing a new callback to the Microtask Queue.
- The Event Loop checks the Microtask Queue again (it must empty it). It runs the second Promise callback, logging “4: Promise 2”.
- The Microtask Queue is finally empty. The Event Loop checks the Macrotask Queue.
- It pulls the first
setTimeoutcallback, logs “2: setTimeout 1”. - It pulls the second
setTimeoutcallback, logs “5: setTimeout 2”.
The final output is: 1, 6, 3, 4, 2, 5.
The Node.js Event Loop Is Not the Browser’s
Everything above describes the browser model: one macrotask, then drain the microtask queue, then render. Node.js uses the same broad idea and a materially different implementation, and code that relies on ordering will behave differently across the two. If you write both frontend and backend JavaScript, this is worth knowing precisely.
Node’s loop, built on libuv, runs through distinct phases in a fixed order on every tick:
| Phase | What it processes |
|---|---|
| timers | Callbacks from setTimeout and setInterval whose threshold has elapsed |
| pending callbacks | Deferred system-level callbacks, such as certain TCP errors |
| poll | Retrieves new I/O events and runs their callbacks — where the loop usually blocks |
| check | setImmediate callbacks |
| close | close handlers, e.g. a destroyed socket |
The microtask queue is drained between each phase, not only at the end of the tick. Node also splits microtasks into two queues with a strict priority: process.nextTick callbacks run before promise continuations, and the entire nextTick queue is exhausted first.
// Run under Node.js. The ordering differs from a browser.
console.log('1: synchronous');
setTimeout(() => console.log('2: setTimeout 0'), 0);
setImmediate(() => console.log('3: setImmediate'));
Promise.resolve().then(() => console.log('4: promise'));
process.nextTick(() => console.log('5: nextTick'));
queueMicrotask(() => console.log('6: queueMicrotask'));
console.log('7: synchronous end');
// Output:
// 1: synchronous
// 7: synchronous end
// 5: nextTick <-- nextTick queue drains FIRST
// 4: promise <-- then the microtask queue, in scheduling order
// 6: queueMicrotask
// 2: setTimeout 0 <-- timers phase
// 3: setImmediate <-- check phase, AFTER timers on this tick
//
// The setTimeout/setImmediate order is the one genuinely unreliable pair:
// inside an I/O callback, setImmediate always wins, because the check phase
// follows poll immediately while timers wait for the next tick.The naming is actively misleading. setImmediate does not run immediately — it runs in the check phase. process.nextTick does not wait for the next tick — it runs before the loop continues at all. Both names predate the phase model.
Starving the Loop
Because the nextTick queue is drained completely before the loop advances, a callback that schedules another nextTick creates a queue that never empties. The process stays responsive to nothing: no timers, no I/O, no incoming requests. It is not a crash, so health checks based on process liveness will report the service as fine while it serves nothing.
// STARVATION: the loop never reaches the timers or poll phase again.
let depth = 0;
function recurse() {
depth++;
process.nextTick(recurse); // re-queues before the loop can advance
}
recurse();
setTimeout(() => console.log('never runs'), 10);
// SAFE: setImmediate yields to the loop between iterations, so timers
// and I/O still get their turn. This is the correct primitive for
// breaking up long synchronous work.
function processChunked(items, index = 0) {
const deadline = Date.now() + 10; // work for ~10ms, then yield
while (index < items.length && Date.now() < deadline) {
handleItem(items[index++]);
}
if (index < items.length) {
setImmediate(() => processChunked(items, index));
}
}The chunking pattern above is the general answer to CPU-bound work on a single-threaded runtime: do a bounded slice, yield, resume. It keeps the loop responsive without the complexity of a worker.
Measuring Loop Lag in Production
Event loop delay is the single most useful health metric for a Node service, and it is more honest than CPU utilisation: a loop blocked by one slow synchronous call shows modest CPU while every request queues behind it. Node exposes it directly.
import { monitorEventLoopDelay } from 'node:perf_hooks';
// resolution is the sampling interval in milliseconds.
const histogram = monitorEventLoopDelay({ resolution: 10 });
histogram.enable();
setInterval(() => {
// Values are in nanoseconds; divide by 1e6 for milliseconds.
const p99 = histogram.percentile(99) / 1e6;
const max = histogram.max / 1e6;
if (p99 > 100) {
console.warn('event loop p99 lag ' + p99.toFixed(1) + 'ms (max ' + max.toFixed(1) + 'ms)');
}
histogram.reset();
}, 10_000).unref(); // unref so this timer never holds the process openA healthy service sits in single-digit milliseconds at p99. Sustained lag above roughly 100ms means something synchronous is blocking the loop, and the usual culprits are large JSON.parse calls, synchronous filesystem functions, and unbounded loops over database results.
When the work is genuinely heavy rather than merely long, yielding is not enough and it belongs on another thread entirely — the tradeoffs are covered in our guide to using Web Workers for threaded JavaScript. Retaining large objects across many loop iterations also interacts with collection pauses, which is covered in JavaScript memory management and garbage collection. The authoritative phase-by-phase description is in the Node.js event loop documentation.
Common Mistakes / Pitfalls
Blocking the Event Loop
Because the Event Loop waits for the Call Stack to be empty, a long-running synchronous operation will completely freeze your application. If you have a massive for loop processing millions of items, the browser cannot render UI updates, process clicks, or execute timers until that loop finishes. Never do heavy synchronous processing on the main thread. If you must process large datasets, break them up using setTimeout or offload the work to a Web Worker.
Assuming setTimeout(…, 0) is Instant
Many developers use setTimeout with a 0ms delay to execute code “immediately after the current stack clears.” While true, it is not actually 0ms. According to the HTML5 specification, nested timers have a minimum delay of 4ms. More importantly, it places the callback at the back of the Macrotask queue. If there are other tasks ahead of it, it will wait.
Best Practices
Use async/await for readability, but understand the underlying Promises. async/await is syntactic sugar over Promises. Everything after an await keyword is essentially wrapped in a .then() and pushed to the Microtask Queue. Understanding this prevents subtle race conditions.
Use Web Workers for heavy lifting. If you are building a complex client-side application (like a video editor or a heavy data visualization tool), move the computation out of the Event Loop entirely. Web Workers run in separate background threads and communicate with the main thread via message passing, keeping your UI silky smooth.
Avoid queueMicrotask for recurring operations. If a microtask recursively queues another microtask, the Event Loop will never move on to rendering the DOM or processing user input. This results in a frozen browser tab.
Conclusion
The JavaScript Event Loop is a brilliant piece of engineering that allows a single-threaded language to handle massive amounts of concurrency. By understanding the distinction between the Call Stack, Web APIs, and the prioritized queues (Micro vs. Macro), you gain precise control over asynchronous execution. You transition from hoping your code executes in the right order to engineering it so it has no choice but to do so.
FAQ
Is Node.js event loop the same as the browser’s?
Conceptually yes, but the implementation is different. Node.js uses the libuv library to handle the event loop, and it has more phases (Timers, Pending Callbacks, Idle/Prepare, Poll, Check, Close Callbacks) than the browser’s simplified model. However, the macro/microtask prioritization is functionally very similar.
Why did my Promise execute before my setTimeout?
Because Promise callbacks are added to the Microtask Queue, while setTimeout callbacks go to the Macrotask Queue. The Event Loop always empties the Microtask Queue completely before processing the next Macrotask.
Does async/await block the thread?
No. await blocks the execution of that specific asynchronous function, but it does not block the main JavaScript thread. The function yields control back to the Event Loop until the awaited Promise resolves.
editor's pick
latest video
news via inbox
Nulla turp dis cursus. Integer liberos euismod pretium faucibua

