Skip to main content
JavaScript · lesson 15 of 24
JavaScript intermediate Lesson 15 of 24

Also 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. "1" and "6" run synchronously — call stack executes top to bottom.
  2. setTimeout registers a macrotask; Promise.resolve().then(...) queues a microtask; queueMicrotask queues a microtask.
  3. Call stack is now empty. Event loop drains the microtask queue: "3", then "5", then "4" (the chained .then was enqueued when "3" resolved).
  4. 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 recursive nextTick starves 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 queueMicrotask enqueue microtasks (higher priority than setTimeout).
  • 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) vs setImmediate from the main module can go either way. Inside an I/O callback, setImmediate always goes first. nextTick vs 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.

  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.

  1. Extend await.js: print user.name in the B line, add a .then to the fetchUser(1) call, and queue one more Promise.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.

  1. ticks.cjs showed nextTick beating a promise in CommonJS. Now schedule from inside a promise callback: call process.nextTick first, then queueMicrotask. Also keep one top-level nextTick. Run it as nested.cjs, then copy it to nested.mjs and 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.

  1. Swap the 200 ms time limit in starve.js for a count limit: rename spin to tick and re-queue it with Promise.resolve().then only while a counter is below 100000. Keep the setTimeout(…, 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.

  1. In chunks.js, swap setTimeout(processChunk, 0) for queueMicrotask(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.

Frequently Asked Questions

Why is JavaScript single-threaded if it can do async work?
JavaScript itself executes on a single thread, but the runtime offloads timers, I/O, and network requests to the host (Web APIs in the browser, libuv in Node.js), which handles them outside your code. The event loop coordinates returning their results to the JS thread when it is idle.
What is the difference between macrotasks and microtasks?
Macrotasks (setTimeout, setInterval, I/O callbacks) are queued in the callback queue and processed one at a time. Microtasks (Promise callbacks, queueMicrotask, MutationObserver) are queued in the microtask queue and all processed before the next macrotask runs.
Does setTimeout(fn, 0) run the callback immediately?
No. setTimeout(fn, 0) schedules a macrotask, so the callback runs after the current call stack is empty and after all pending microtasks have been flushed. Node treats 0 as 1 ms, and the delay is a minimum, not a guarantee.
Does setImmediate run before setTimeout(fn, 0) in Node.js?
Not reliably from the main module. The order depends on whether the 1 ms timer is already due when the event loop starts, and repeated runs show both orders. Inside an I/O callback such as fs.readFile, setImmediate always runs first, because the check phase comes right after the poll phase, before the loop returns to timers.
Does process.nextTick always run before Promise callbacks?
No. In a CommonJS script, nextTick callbacks queued at the top level run before promise callbacks. In an ES module (a .mjs file or "type": "module"), the module's promise callbacks run first. And once Node is draining the promise queue, microtasks added during that drain run before any newly queued nextTick callbacks.