JavaScript · lesson 15 of 24
Beginner
11- 1 Introduction to JavaScript
- 2 Setting Up JavaScript Development Environment
- 3 Variables in JavaScript: var, let, and const
- 4 JavaScript Data Types
- 5 JavaScript Operators
- 6 Control Flow in JavaScript
- 7 Functions in JavaScript
- 8 Working with Strings in JavaScript
- 9 Arrays in JavaScript
- 10 Objects in JavaScript
- 18 DOM Manipulation in JavaScript
Intermediate
10- 11 Error Handling in JavaScript
- 12 Object-Oriented Programming in JavaScript
- 13 Prototypes and Prototype Chain in JavaScript
- 14 Closures in JavaScript
- 15 JavaScript Event Loop Explained
- 16 Asynchronous JavaScript: Callbacks, Promises, and Async/Await
- 17 JavaScript Modules: ESM and CommonJS
- 19 Browser Web APIs in JavaScript
- 20 Testing JavaScript with Jest and Vitest
- 24 JavaScript Interview Preparation: Top 35 Questions
Advanced
3Also part of: TypeScript
JavaScript Event Loop Explained
Understand how JavaScript's single-threaded event loop works, why microtasks run before macrotasks, and how to reason about async execution order.
By SkillByExample · Updated
JavaScript runs on a single thread — there is no parallel execution of your code. Yet it handles HTTP requests, timers, and user events without blocking. The event loop is the mechanism that makes this possible. Understanding it is not just academic — it explains why async code runs in the order it does, why the UI can freeze, and how to design code that stays responsive.
The Mental Model
Think of the runtime as four interacting components. Each plays a specific role:
- Call Stack — a LIFO stack that tracks which function is currently executing. Synchronous code runs here, frame by frame.
- Heap — unstructured memory where objects are allocated.
- Web APIs (browser) / libuv (Node.js) — handles timers, network I/O, and DOM events outside the JS thread. These run in parallel with your code.
- Task Queues — two queues: the microtask queue (high priority) and the callback queue / macrotask queue (lower priority).
The event loop rule is simple: when the call stack is empty, drain all microtasks, then pick one macrotask, then repeat.
A Concrete Execution Trace
Every example below is a complete file you can run with Node.js (the outputs come from Node
v24.18.1). They are plain .js files with no package.json nearby, so Node runs them as
CommonJS. That only matters in the process.nextTick section, where the files are named
.cjs and .mjs to make the module type explicit.
Predict the order of the six lines before you look at the output:
console.log("1 — synchronous start");
setTimeout(() => console.log("2 — macrotask (setTimeout)"), 0);
Promise.resolve()
.then(() => console.log("3 — microtask (Promise .then)"))
.then(() => console.log("4 — microtask (chained .then)"));
queueMicrotask(() => console.log("5 — microtask (queueMicrotask)"));
console.log("6 — synchronous end");
$ node trace.js
1 — synchronous start
6 — synchronous end
3 — microtask (Promise .then)
5 — microtask (queueMicrotask)
4 — microtask (chained .then)
2 — macrotask (setTimeout)
Step-by-step:
"1"and"6"run synchronously — call stack executes top to bottom.setTimeoutregisters a macrotask;Promise.resolve().then(...)queues a microtask;queueMicrotaskqueues a microtask.- Call stack is now empty. Event loop drains the microtask queue:
"3", then"5", then"4"(the chained.thenwas enqueued when"3"resolved). - Microtask queue is empty. Event loop picks the next macrotask:
"2".
Microtasks Run to Completion
All microtasks queued during microtask processing are also drained before any macrotask runs. This is by design — it ensures Promise chains resolve completely before the browser gets a chance to repaint or run timers. The downside is that a microtask that keeps queuing itself starves the macrotask queue. This program does that for 200 ms, with a 0 ms timer waiting:
const start = Date.now();
let count = 0;
setTimeout(() => {
console.log(`timeout ran at ${Date.now() - start} ms, after ${count} microtasks`);
}, 0);
function spin() {
count++;
// Re-queue for 200 ms. Without this time check, the loop never ends.
if (Date.now() - start < 200) queueMicrotask(spin);
}
spin();
console.log("sync code done");
$ node starve.js
sync code done
timeout ran at 200 ms, after 2192088 microtasks
The timer was due after about 1 ms, but it ran at 200 ms, the moment the microtask chain stopped.
The count varies from machine to machine and run to run. What stays the same is that the timeout
never runs before the chain ends. Drop the Date.now() check and the chain never ends, so the
timer never fires and the process hangs. In a browser, the tab freezes, because rendering waits
for the microtask queue too.
The Call Stack in Practice
Synchronous code fills the call stack frame by frame. Each function call pushes a frame and each return pops it. This program logs each push and pop, indented by how deep the stack is:
let depth = 0;
const enter = (name) => console.log(" ".repeat(depth++) + "push " + name);
const leave = (name) => console.log(" ".repeat(--depth) + "pop " + name);
function add(a, b) {
enter("add");
const sum = a + b;
leave("add");
return sum;
}
function calculate(x) {
enter("calculate");
const result = add(x, 10);
leave("calculate");
return result;
}
function main() {
enter("main");
const result = calculate(5);
console.log(" ".repeat(depth) + "result = " + result);
leave("main");
}
main();
$ node stack.js
push main
push calculate
push add
pop add
pop calculate
result = 15
pop main
add is pushed last and popped first: the stack is last in, first out. Once main is popped the
stack is empty, and only then can the event loop run a queued callback.
The stack has a fixed size. A recursive call with no base case keeps pushing frames until it runs out of room:
let depth = 0;
function infinite() {
depth++;
infinite(); // no base case
}
try {
infinite();
} catch (err) {
console.log(err.name + ": " + err.message);
console.log("frames pushed before the crash:", depth);
}
$ node overflow.js
RangeError: Maximum call stack size exceeded
frames pushed before the crash: 12507
The overflow is an ordinary RangeError that you can catch. The frame count depends on the
engine, its stack size and how big each frame is, so expect a different number on your machine.
setTimeout(fn, 0) is Not Instantaneous
setTimeout(fn, 0) is a common pattern, but many developers assume it runs “right away.” In reality, it schedules a macrotask — meaning it will run after the current synchronous code finishes AND after all pending microtasks are drained.
function render() {
console.log("render");
}
setTimeout(render, 0);
Promise.resolve().then(() => console.log("promise"));
console.log("sync");
$ node render.js
sync
promise
render
The timer was scheduled first, but render comes last. Synchronous code finishes, then the
microtask queue drains (promise), and only then does the loop reach the timer.
Use setTimeout(fn, 0) to yield to the browser’s rendering pipeline (e.g., let a repaint happen before heavy work). Use queueMicrotask() when you need to defer to the end of the current task but before any I/O or rendering.
Practical: Avoiding UI Jank with Chunked Work
Long synchronous tasks block the call stack and freeze the UI — the browser cannot repaint or respond to input while JavaScript is running. The fix is to break heavy work into smaller chunks and yield between them, giving the browser time to paint and stay interactive.
In this program a “click handler” timer is queued while 400 items are processed in chunks of 100:
function heavyComputation(item) {
let x = 0;
for (let i = 0; i < 100000; i++) x += item;
return x;
}
function processInChunks(items, chunkSize = 100) {
let index = 0;
function processChunk() {
const end = Math.min(index + chunkSize, items.length);
for (; index < end; index++) heavyComputation(items[index]);
console.log("chunk done, index =", index);
if (index < items.length) {
setTimeout(processChunk, 0); // yield to the event loop between chunks
}
}
processChunk();
}
processInChunks(Array.from({ length: 400 }, (_, i) => i));
setTimeout(() => console.log("click handler ran"), 0);
$ node chunks.js
chunk done, index = 100
chunk done, index = 200
click handler ran
chunk done, index = 300
chunk done, index = 400
The click handler does not wait for all 400 items. The first chunk runs synchronously and queues chunk two before the click timer is queued, so chunk two goes first. The click then gets its turn before chunk three. If all 400 items ran in one loop, the click would wait for the whole job. This order held on all 20 of 20 runs.
For modern code, prefer requestIdleCallback (browser) for low-priority background work, or scheduler.postTask when available.
async/await and the Event Loop
async/await is syntax sugar over Promises. An await expression suspends the async function and enqueues the rest of the function as a microtask once the awaited Promise settles. This means code after await does not run synchronously — it runs as a microtask after the current call stack empties.
function getUser(id) {
return Promise.resolve({ id, name: "Ada" });
}
async function fetchUser(id) {
console.log("A — before await");
const user = await getUser(id);
console.log("B — after await");
return user;
}
console.log("1 — before call");
fetchUser(1);
console.log("2 — after call (synchronous)");
$ node await.js
1 — before call
A — before await
2 — after call (synchronous)
B — after await
A prints synchronously, because an async function runs normally up to its first await. There
it suspends and returns control to the caller, so 2 prints next, even though getUser had
already resolved. B runs as a microtask once the stack is empty.
Node.js Additions
Node.js adds two more scheduling mechanisms:
process.nextTick()has its own queue, which Node drains alongside the promise microtask queue. Use it sparingly: a recursivenextTickstarves I/O just like a recursive promise.setImmediate()runs in the check phase, right after the poll (I/O) phase of each loop iteration. The phases go timers (setTimeout) → pending callbacks → poll (I/O) → check (setImmediate) → close callbacks; only timers, poll and check matter here. Then back to timers.
nextTick vs promises depends on the module type
Here is the same four-line program saved twice, once as ticks.cjs (CommonJS) and once as
ticks.mjs (an ES module):
setTimeout(() => console.log("timeout"), 0);
Promise.resolve().then(() => console.log("promise"));
process.nextTick(() => console.log("nextTick"));
console.log("sync");
$ node ticks.cjs
sync
nextTick
promise
timeout
$ node ticks.mjs
sync
promise
nextTick
timeout
In CommonJS, nextTick beats the promise. When a CommonJS script finishes, Node drains the
nextTick queue first and the promise queue second. Node loads an ES module asynchronously, from inside
promise machinery. So the promise microtasks the module queues drain in the same microtask
checkpoint, before Node gets back to its nextTick queue. Either way, both queues are empty before the timer runs. Plain
.js files follow the nearest package.json: "type": "module" makes them behave like
ticks.mjs.
setTimeout(0) vs setImmediate from the main module
Which of these runs first? This program records the order and prints it on exit:
const order = [];
setTimeout(() => order.push("timeout"), 0);
setImmediate(() => order.push("immediate"));
process.on("exit", () => console.log(order.join(" -> ")));
Running it once proves nothing, so this run does it 200 times and counts each ordering:
$ for i in $(seq 200); do node race.js; done | sort | uniq -c
189 immediate -> timeout
11 timeout -> immediate
Both orders happen. setTimeout(fn, 0) really means “after at least 1 ms”. When the loop first
reaches the timers phase, the timer may or may not be due yet. If not, the loop moves on to check
and setImmediate wins. On this machine that happened most of the time, and a 20-run loop showed
only immediate -> timeout. Your split will differ. Don’t rely on either order.
The race goes away if the timer is due before the loop starts. Add 5 ms of synchronous work after scheduling both callbacks:
const order = [];
setTimeout(() => order.push("timeout"), 0);
setImmediate(() => order.push("immediate"));
const start = Date.now();
while (Date.now() - start < 5) {} // 5 ms of sync work: the 1 ms timer is now due
process.on("exit", () => console.log(order.join(" -> ")));
$ for i in $(seq 20); do node race-busy.js; done | sort | uniq -c
20 timeout -> immediate
Inside an I/O callback, setImmediate always wins
Scheduling both from inside an I/O callback removes the race for a different reason:
const fs = require("fs");
const order = [];
fs.readFile(__filename, () => {
// We are now inside the poll (I/O) phase.
setTimeout(() => order.push("timeout"), 0);
setImmediate(() => order.push("immediate"));
});
process.on("exit", () => console.log(order.join(" -> ")));
$ for i in $(seq 200); do node io-race.js; done | sort | uniq -c
200 immediate -> timeout
The readFile callback runs in the poll phase. The next phase is always check, so setImmediate
fires before the loop wraps back around to timers, however long the timer has been due. If you
need “run this right after the current I/O”, setImmediate is the reliable choice.
Key Takeaways
- The call stack runs synchronous code; Web APIs handle async work off-thread.
- When the stack empties, drain all microtasks, then run one macrotask, repeat.
- Promises and
queueMicrotaskenqueue microtasks (higher priority thansetTimeout). setTimeout(fn, 0)guarantees only that the callback runs after the current microtask queue is empty.- Never block the call stack with long synchronous loops — break work into chunks, and yield
with a macrotask (
setTimeout), not a microtask. - In Node.js,
setTimeout(0)vssetImmediatefrom the main module can go either way. Inside an I/O callback,setImmediatealways goes first.nextTickvs promise order depends on CommonJS vs ESM.
Practice
Write down your predicted output for each task before you run it. All solutions were run with Node v24.18.1.
- Extend the execution trace: schedule two
setTimeout(…, 0)callbacks, and inside the first one queue a Promise microtask. Does that microtask run before or after the second timeout?
Solution
console.log("A — sync");
setTimeout(() => {
console.log("B — first timeout");
Promise.resolve().then(() => console.log("C — microtask queued inside B"));
}, 0);
setTimeout(() => console.log("D — second timeout"), 0);
Promise.resolve().then(() => console.log("E — microtask"));
console.log("F — sync");
$ node order.js
A — sync
F — sync
E — microtask
B — first timeout
C — microtask queued inside B
D — second timeout
The loop runs one macrotask and then drains every microtask before picking the next one. So C,
queued while B was running, jumps ahead of D even though D was scheduled much earlier.
- Extend
await.js: printuser.namein theBline, add a.thento thefetchUser(1)call, and queue one morePromise.resolve().then(...)right after the call. Predict where all three microtask lines land.
Solution
function getUser(id) {
return Promise.resolve({ id, name: "Ada" });
}
async function fetchUser(id) {
console.log("A — before await");
const user = await getUser(id);
console.log("B — after await:", user.name);
return user;
}
console.log("1 — before call");
fetchUser(1).then(user => console.log("C — caller's .then:", user.id));
Promise.resolve().then(() => console.log("P — other microtask"));
console.log("2 — after call (synchronous)");
$ node await.js
1 — before call
A — before await
2 — after call (synchronous)
B — after await: Ada
P — other microtask
C — caller's .then: 1
A runs synchronously because an async function executes up to its first await. The rest of
fetchUser was queued before P, so B comes first. The caller’s .then waits for fetchUser
to return, which only happens after B, so C comes last.
ticks.cjsshowednextTickbeating a promise in CommonJS. Now schedule from inside a promise callback: callprocess.nextTickfirst, thenqueueMicrotask. Also keep one top-levelnextTick. Run it asnested.cjs, then copy it tonested.mjsand run that too.
Solution
Promise.resolve().then(() => {
console.log("promise callback");
process.nextTick(() => console.log("nextTick (queued inside the promise)"));
queueMicrotask(() => console.log("queueMicrotask (queued inside the promise)"));
});
process.nextTick(() => console.log("nextTick (queued at top level)"));
console.log("sync");
$ node nested.cjs
sync
nextTick (queued at top level)
promise callback
queueMicrotask (queued inside the promise)
nextTick (queued inside the promise)
$ node nested.mjs
sync
promise callback
queueMicrotask (queued inside the promise)
nextTick (queued at top level)
nextTick (queued inside the promise)
nextTick does not always win, even in CommonJS. Once the promise queue is draining, it keeps
going until it is empty, so the queueMicrotask added during the drain runs before the nextTick
scheduled just before it. Node only checks the nextTick queue again afterwards. In the ES module
the top-level nextTick loses too, for the reason ticks.mjs showed.
- Swap the 200 ms time limit in
starve.jsfor a count limit: renamespintotickand re-queue it withPromise.resolve().thenonly while a counter is below 100000. Keep thesetTimeout(…, 0)scheduled before the first call. How many microtasks have run by the time the timeout fires?
Solution
let count = 0;
setTimeout(() => console.log("timeout ran after", count, "microtasks"), 0);
function tick() {
count++;
if (count < 100000) Promise.resolve().then(tick); // a count limit, not a time limit
}
tick();
console.log("sync code done, count =", count);
$ node starve-count.js
sync code done, count = 1
timeout ran after 100000 microtasks
The timer was ready almost at once, but it still had to wait for all 100000 chained microtasks.
The count < 100000 guard is the only thing that lets the chain end and the timer run.
- In
chunks.js, swapsetTimeout(processChunk, 0)forqueueMicrotask(processChunk). It still splits the work into chunks. Does the click handler still get a turn in the middle?
Solution
function heavyComputation(item) {
let x = 0;
for (let i = 0; i < 100000; i++) x += item;
return x;
}
function processInChunks(items, chunkSize = 100) {
let index = 0;
function processChunk() {
const end = Math.min(index + chunkSize, items.length);
for (; index < end; index++) heavyComputation(items[index]);
console.log("chunk done, index =", index);
if (index < items.length) {
queueMicrotask(processChunk); // was setTimeout(processChunk, 0)
}
}
processChunk();
}
processInChunks(Array.from({ length: 400 }, (_, i) => i));
setTimeout(() => console.log("click handler ran"), 0);
$ node chunks-micro.js
chunk done, index = 100
chunk done, index = 200
chunk done, index = 300
chunk done, index = 400
click handler ran
No. Each chunk queues the next as a microtask, and the loop drains every microtask before it
touches the timer queue. So all four chunks run back to back and the click waits for the whole
job. That is exactly what chunking was meant to avoid. Splitting work into pieces only helps if
the code yields with a macrotask, such as setTimeout, setImmediate or scheduler.postTask.