Demystifying the JavaScript Event Loop: A Practical Guide to Concurrency in Modern Apps
Introduction
JavaScript is famously single‑threaded, yet we write highly responsive web apps, server‑side services, and even desktop tools that appear to run many things at once. The secret sauce is the event loop—a lightweight scheduler that coordinates macrotasks, microtasks, and I/O callbacks. Understanding how this machinery works is essential for writing performant code, avoiding subtle bugs, and making the most of modern APIs like Promise, async/await, and Web Workers.
This article walks through the event loop step‑by‑step, explains the concurrency model, and shows practical patterns you can adopt today.
1. The Core Concepts
| Concept | What it is | Where it lives |
|---|---|---|
| Call Stack | The execution context stack where synchronous code runs. | Main JavaScript thread |
| Macrotask Queue (or Task Queue) | Holds callbacks from setTimeout, setInterval, I/O, UI events, etc. |
Managed by the host (browser/Node) |
| Microtask Queue | Holds Promise reaction jobs and queueMicrotask callbacks. |
Processed after each macrotask, before rendering |
| Event Loop | Repeatedly picks the next macrotask, runs it, then drains the microtask queue. | Host‑provided scheduler |
The event loop never runs two pieces of JavaScript simultaneously. Instead, it interleaves work: a macrotask runs to completion, then all pending microtasks run, then the browser may repaint, and the cycle repeats.
2. A Minimal Visualisation
┌─────────────────────┐
│ Call Stack (JS) │
└─────────▲───────────┘
│
┌──────┴───────┐
│ Event Loop │
└──────▲───────┘
│
┌──────┴───────┐
│ Macrotask Q │ ← setTimeout, I/O, UI events
└──────▲───────┘
│
┌──────┴───────┐
│ Microtask Q │ ← Promise jobs, queueMicrotask
└──────────────┘
After a macrotask finishes, the loop empties the microtask queue before moving to the next macrotask. This ordering explains many “surprising” behaviours in async code.
3. Real‑World Example: Ordering with setTimeout and Promises
console.log('A');
setTimeout(() => console.log('B'), 0);
Promise.resolve()
.then(() => console.log('C'))
.then(() => console.log('D'));
console.log('E');
Output
A
E
C
D
B
Why?
- Synchronous logs (
A,E) run on the call stack. setTimeoutschedules a macrotask (B).Promise.resolve().thencreates two microtasks (C,D). They run immediately after the current macrotask (the script) finishes.- Only after the microtask queue is empty does the event loop pick the next macrotask (
B).
4. Macrotasks vs. Microtasks – When to Use Which
| Situation | Prefer Macrotask | Prefer Microtask |
|---|---|---|
| UI repaint after state change | requestAnimationFrame (macrotask) |
— |
| Deferring heavy computation without blocking UI | setTimeout(..., 0) |
— |
| Chaining async work that must run before the next render | Promise.then / queueMicrotask |
✅ |
| Scheduling work that should not starve the UI | setTimeout with a small delay |
❌ (microtasks run before paint) |
Rule of thumb: Use microtasks for logical continuation of the current task (e.g., resolve a promise). Use macrotasks when you need to yield back to the host, giving it a chance to handle I/O, repaint, or user input.
5. The Event Loop in Node.js
Node adds a few extra queues:
| Queue | Typical Sources |
|---|---|
| Timers | setTimeout, setInterval |
| I/O Callbacks | File system, network sockets |
| Idle, Prepare | Internal bookkeeping |
| Poll | Retrieves new I/O events |
| Check | setImmediate callbacks |
| Close Callbacks | socket.on('close') |
Node’s setImmediate is a macrotask that runs after the poll phase, making it useful for “run after I/O but before timers”. In browsers, the closest analogue is requestAnimationFrame for UI‑aligned work.
Node example
const fs = require('fs');
fs.readFile('data.txt', (err, data) => {
console.log('file read'); // I/O callback (macrotask)
});
setImmediate(() => console.log('immediate')); // runs after I/O callbacks
setTimeout(() => console.log('timeout'), 0); // runs after immediate
Typical output:
file read
immediate
timeout
6. Async/Await – Syntactic Sugar Over Promises
async functions pause execution at each await. Under the hood, the pause is implemented by returning a promise and queuing the continuation as a microtask.
async function fetchAndLog(url) {
console.log('start');
const resp = await fetch(url); // pause, schedule continuation as microtask
console.log('status', resp.status);
}
console.log('before');
fetchAndLog('https://api.example.com');
console.log('after');
Possible output
before
start
after
status 200
The await does not block the thread; it merely yields control back to the event loop, allowing other macrotasks (e.g., UI events) to run while the network request resolves.
7. Avoiding Common Pitfalls
7.1. Unbounded Microtask Queues
If a microtask enqueues another microtask, the loop can starve the macrotask queue, causing UI freezes.
function flood() {
Promise.resolve().then(flood);
}
flood(); // ❌ infinite microtask recursion
Fix: Use setTimeout or queueMicrotask with a guard, or restructure logic to avoid recursive microtasks.
7.2. Mixing setTimeout(..., 0) and Promise.then
Developers sometimes think setTimeout(..., 0) is “next tick”. It’s actually a macrotask, so any then callbacks will run before it, which can be surprising.
7.3. Blocking the Call Stack
Heavy synchronous loops (while(true){}) block the event loop entirely, preventing any task from executing. Offload CPU‑intensive work to Web Workers (browser) or worker threads (Node) to keep the main thread responsive.
8. Practical Patterns
8.1. Debouncing with Microtasks
When you need to batch rapid UI updates (e.g., on input events), a microtask‑based debounce is ultra‑lightweight:
let pending = false;
function microDebounce(fn) {
if (!pending) {
pending = true;
queueMicrotask(() => {
pending = false;
fn();
});
}
}
input.addEventListener('input', e => microDebounce(() => render(e.target.value)));
Because microtasks run before the next paint, the UI updates only once per animation frame.
8.2. Throttling with requestAnimationFrame
For animation‑related work, combine the event loop with the browser’s paint cycle:
let ticking = false;
function rafThrottle(callback) {
return function (...args) {
if (!ticking) {
ticking = true;
requestAnimationFrame(() => {
ticking = false;
callback.apply(this, args);
});
}
};
}
window.addEventListener('scroll', rafThrottle(() => {
// expensive DOM calculations
}));
8.3. Graceful Shutdown in Node
When a server receives SIGTERM, you often want to finish pending requests before exiting. Use the microtask queue to wait for all in‑flight promises:
process.on('SIGTERM', async () => {
console.log('Shutting down...');
await Promise.all(activeRequests); // microtasks
server.close(() => process.exit(0));
});
The await ensures the event loop processes any remaining microtasks (e.g., DB writes) before the process exits.
9. Visual Debugging Tools
| Tool | What it Shows |
|---|---|
| Chrome DevTools → Performance | Timeline of macrotasks, microtasks, and paint events |
Node --trace-event |
Low‑level trace of libuv phases and task queues |
async_hooks (Node) |
Hook into promise lifecycle to see when microtasks are created/completed |
Using these tools, you can spot unexpected microtask bursts or long‑running macrotasks that degrade responsiveness.
10. Summary
- The JavaScript runtime is single‑threaded, but the event loop orchestrates asynchronous work via macrotasks and microtasks.
- Microtasks (
Promise.then,queueMicrotask) run immediately after the current macrotask, before any rendering. - Macrotasks (
setTimeout, I/O callbacks,setImmediate) give the host a chance to handle I/O, repaint, and user input. - Understanding the ordering lets you write deterministic async code, avoid starvation, and choose the right primitive for each scenario.
- Real‑world patterns—micro‑debounce, RAF throttling, graceful shutdown—leverage the event loop to keep apps responsive and robust.
Mastering the event loop isn’t just academic; it’s the foundation for writing fast, reliable JavaScript—whether you’re building a single‑page UI, a high‑throughput API, or a desktop Electron app.
Member discussion