Of everything I’ve learned about JavaScript, the event loop is the concept that most transformed how I debug asynchronous code. I used to just accept “async stuff happens later” without understanding exactly when “later” was. Once I traced through the event loop mechanics carefully, timing bugs that used to feel random became completely predictable. This article is my attempt to give you that same clarity.
Why the Event Loop Exists
JavaScript runs on a single thread — one call stack, one thing executing at a time. But browsers and Node.js need to handle things like timers, network requests, and user input without freezing everything else while waiting. The event loop is the mechanism that reconciles these two facts: a single-threaded language handling seemingly concurrent operations.
The Core Pieces
1. The Call Stack
The call stack tracks function calls. When a function is invoked, it’s pushed on; when it returns, it’s popped off.
function a() { b(); }
function b() { c(); }
function c() { console.log("c running"); }
a();
// Stack grows: a -> b -> c
// Then unwinds: c pops, b pops, a pops
2. Web APIs / Node APIs
Asynchronous operations like setTimeout, fetch, DOM events, and file I/O aren’t handled by the JavaScript engine itself — they’re delegated to the surrounding runtime environment (the browser’s C++ APIs, or Node’s libuv), which handles the actual waiting outside the single JS thread.
3. The Microtask Queue
Holds callbacks from Promises (.then, .catch, .finally), queueMicrotask(), and async/await continuations. This queue has higher priority than the macrotask queue.
4. The Macrotask Queue (Task Queue)
Holds callbacks from setTimeout, setInterval, I/O events, and UI rendering-related tasks. Only one macrotask is processed per event loop iteration.
The Event Loop Algorithm
At a conceptual level, the event loop repeatedly does this:
- Execute everything currently on the call stack until it’s empty.
- Once the stack is empty, process the entire microtask queue, one at a time, until it’s completely empty — including any new microtasks added during this processing.
- Only after the microtask queue is fully drained, take one task from the macrotask queue, push it onto the stack, and run it.
- Optionally perform rendering (in browsers).
- Repeat from step 2.
This “microtask queue must be fully empty before the next macrotask” rule is the single most important detail for predicting execution order correctly.
Walking Through a Classic Example
console.log("1: Script start");
setTimeout(() => console.log("2: setTimeout"), 0);
Promise.resolve()
.then(() => console.log("3: Promise 1"))
.then(() => console.log("4: Promise 2"));
console.log("5: Script end");
Output:
1: Script start
5: Script end
3: Promise 1
4: Promise 2
2: setTimeout
Step by step:
"1: Script start"runs synchronously.setTimeoutregisters its callback with the browser’s timer API and returns immediately — the callback is scheduled as a macrotask once the delay elapses.Promise.resolve().then(...)schedules its callback as a microtask."5: Script end"runs synchronously — the main script finishes, and the call stack is now empty.- The event loop checks the microtask queue: it finds
"3: Promise 1", runs it, which itself schedules"4: Promise 2"as another microtask — since the microtask queue isn’t considered “empty” until nothing new gets added,"4: Promise 2"also runs before moving on. - Only now, with the microtask queue fully drained, does the event loop take the next macrotask — the
setTimeoutcallback — and run"2: setTimeout".
Async/Await and the Event Loop
async/await is built directly on Promises, so it uses the exact same microtask queue.
console.log("A");
async function demo() {
console.log("B");
await null;
console.log("C");
}
demo();
console.log("D");
Output: A, B, D, C
The function runs synchronously up to the await, at which point it suspends and schedules its continuation (console.log("C")) as a microtask, letting the caller’s synchronous code (console.log("D")) run first.
Rendering and the Event Loop (Browser-Specific)
In browsers, the event loop also coordinates with rendering. After the microtask queue is drained (and typically after each macrotask), the browser may perform a rendering step — recalculating styles, layout, and painting — if enough time has passed and there’s something new to display. This is why long-running microtask chains (like an infinite chain of .then() calls) can actually block rendering entirely, since the browser won’t get a chance to paint until the microtask queue is empty.
function infiniteMicrotasks() {
Promise.resolve().then(infiniteMicrotasks); // never lets the microtask queue empty!
}
infiniteMicrotasks();
// This can freeze the UI, since rendering never gets a chance to happen
I’ve genuinely seen this pattern accidentally cause a frozen page in production code — a recursive .then() chain with no exit condition.
requestAnimationFrame vs. setTimeout
function animate() {
console.log("frame");
requestAnimationFrame(animate);
}
requestAnimationFrame(animate);
requestAnimationFrame schedules its callback to run right before the browser’s next repaint, synced to the display’s refresh rate (usually ~60fps) — I use this instead of setTimeout for any visual animation, since it’s optimized specifically for smooth rendering and automatically pauses in inactive/background tabs, saving battery and CPU.
Node.js: Event Loop Phases
Node.js implements a more elaborate event loop with distinct phases, each with its own queue.
| Phase | Purpose |
|---|---|
| Timers | Runs setTimeout/setInterval callbacks whose timer has expired |
| Pending callbacks | Executes I/O callbacks deferred from the previous cycle |
| Idle, prepare | Internal use |
| Poll | Retrieves new I/O events, executes I/O callbacks |
| Check | Executes setImmediate() callbacks |
| Close callbacks | e.g., socket.on('close') |
Additionally, process.nextTick() has its own queue that runs before the microtask (Promise) queue, at the end of every phase — making it even higher priority than Promises in Node.js specifically.
console.log("start");
setTimeout(() => console.log("timeout"), 0);
setImmediate(() => console.log("immediate"));
process.nextTick(() => console.log("nextTick"));
Promise.resolve().then(() => console.log("promise"));
console.log("end");
// Typical output:
// start
// end
// nextTick
// promise
// timeout (or immediate — order between these two can vary depending on context)
// immediate (or timeout)
I use process.nextTick() sparingly and deliberately, since overusing it can starve I/O callbacks from ever running if recursive nextTick calls keep the queue perpetually non-empty.
Internal Working: Why Microtasks Take Priority
The ECMAScript specification defines Promise reactions as jobs that must be processed as part of a “microtask checkpoint” — a required step the host environment (browser or Node.js) must perform whenever the JavaScript call stack becomes empty, before yielding control back to the event loop’s outer mechanisms like timers or I/O. This isn’t an implementation detail specific to one engine — it’s mandated by the spec itself, which is why this microtask-before-macrotask ordering is consistent across all standards-compliant JavaScript environments.
Practical, Real-World Applications
Avoiding UI Freezes with Chunked Processing
function processLargeArray(items, chunkSize = 100) {
let index = 0;
function processChunk() {
const end = Math.min(index + chunkSize, items.length);
for (; index < end; index++) {
// process items[index]
}
if (index < items.length) {
setTimeout(processChunk, 0); // yield back to the event loop between chunks
}
}
processChunk();
}
By using setTimeout(fn, 0) between chunks, I let the event loop process other pending tasks (like user input or rendering) between batches, instead of blocking the entire thread until all items are processed.
Debugging Execution Order
console.log("1");
setTimeout(() => console.log("2"), 0);
queueMicrotask(() => console.log("3"));
Promise.resolve().then(() => console.log("4"));
console.log("5");
// Output: 1, 5, 3, 4, 2
queueMicrotask() gives me direct, explicit access to the microtask queue without needing to wrap something in a Promise — useful when I want microtask timing without the overhead of Promise semantics.
Best Practices
- I avoid long, blocking synchronous code, since it freezes the entire thread, including rendering and user interaction.
- I use
requestAnimationFramefor visual animations instead ofsetTimeout. - I break up large synchronous workloads into chunks using
setTimeoutorrequestIdleCallbackto avoid freezing the UI. - I’m cautious with recursive
.then()chains orprocess.nextTick()calls that could starve the event loop.
Common Mistakes to Avoid
- Assuming
setTimeout(fn, 0)runs immediately — it always waits for the current call stack and the entire microtask queue to clear first, at minimum. - Writing recursive microtask chains with no exit, which can block rendering entirely in browsers.
- Overusing
process.nextTick()in Node.js, potentially starving I/O. - Confusing concurrency (interleaving tasks) with parallelism (simultaneous execution) — JavaScript’s event loop provides the former, not the latter, without Workers.
Debugging Tips
- I use the Performance tab in Chrome DevTools to visually inspect the call stack, microtasks, and macrotasks over time.
- I add sequential
console.logstatements with labels when execution order is unclear, then reason through the microtask/macrotask rules step by step. - In Node.js, I use
--trace-sync-ioto catch accidental blocking I/O calls that stall the event loop.
FAQs
Q: Does setTimeout(fn, 0) run before or after Promise callbacks? A: Always after — Promise callbacks are microtasks and are fully processed before the next macrotask, regardless of the requested timer delay.
Q: Can the event loop run two callbacks at the exact same time? A: No, JavaScript’s single-threaded model means only one callback runs at any given instant, even though multiple asynchronous operations are “in flight” via the runtime.
Q: What’s the difference between the microtask queue and the macrotask queue? A: Microtasks (Promises, queueMicrotask) are fully drained before the event loop processes the next macrotask (setTimeout, events, I/O), which only processes one item per event loop iteration.
Q: Why is process.nextTick() considered even higher priority than Promises in Node.js? A: Because Node.js processes the entire nextTick queue before it even begins processing the microtask (Promise) queue, at the end of each event loop phase.
Summary and Key Takeaways
- JavaScript is single-threaded, but the event loop coordinates the call stack, Web/Node APIs, and task queues to enable non-blocking asynchronous behavior.
- The microtask queue (Promises,
async/awaitcontinuations) is fully drained before the macrotask queue (setTimeout, events) is touched. - Node.js has additional phases and a separate, even-higher-priority
process.nextTick()queue. - Long, blocking synchronous code or endless microtask chains can freeze rendering and the entire application.
- Understanding this model precisely — not just “async runs later” — makes previously confusing timing bugs completely predictable.
Once I could trace through exactly what the event loop does, step by step, debugging async timing issues stopped being guesswork and became a process I could reason through with confidence every time.
