Skip to content
C Codeloom

Courses / JavaScript Foundations

Lesson 12 of 12

The JavaScript Event Loop, Explained Clearly

A deep but practical look at the JavaScript event loop: call stack, microtasks, macrotasks, and how async code actually runs in the browser and Node.

Intermediate 10 min read

What you'll learn

  • How the call stack and task queues fit together
  • The difference between microtasks and macrotasks
  • Why Promises resolve before setTimeout(0)
  • How Node and browsers differ subtly
  • How to avoid blocking the main thread

Prerequisites

  • Comfortable with JavaScript functions

JavaScript is single-threaded, which means one call stack and one piece of code running at a time. Yet your apps handle clicks, timers, network responses, and animations all at once. That illusion of concurrency comes from the event loop: a small coordinator that decides what runs next. Once you understand the rules, async code stops being mysterious.

What and why

The runtime has three core pieces: the call stack, the task queues, and the event loop itself. The call stack is where function invocations live. Each function pushes a frame; returning pops it. When the stack is empty, the event loop picks the next pending task from a queue and runs it on the stack.

There are two queue tiers that matter most: microtasks and macrotasks. Microtasks (Promise callbacks, queueMicrotask, MutationObserver) run after the current task finishes but before any new macrotask. Macrotasks (timers, I/O callbacks, UI events, setImmediate in Node) run one per loop iteration. This ordering is the source of most “why did this run before that?” surprises.

Mental model

Think of the event loop as a bouncer at a club. It only lets one task into the call stack at a time. After each task, it drains all pending microtasks. Then it lets the next macrotask in.

while (true) {
task = macrotaskQueue.shift()
run(task)            // call stack processes it
while (microtaskQueue.length) {
  run(microtaskQueue.shift())
}
render()             // browsers paint here, if needed
}
Event loop tick

This is why Promise.resolve().then(fn) always runs before a setTimeout(fn, 0) scheduled at the same time: the promise callback is a microtask and gets drained at the end of the current task, while the timer is a macrotask waiting for the next loop iteration.

Hands-on example

console.log('1');

setTimeout(() => console.log('2'), 0);

Promise.resolve().then(() => console.log('3'));

queueMicrotask(() => console.log('4'));

console.log('5');

Output: 1, 5, 3, 4, 2.

Why? 1 and 5 are synchronous. 3 and 4 are microtasks queued during the synchronous task; they drain before the macrotask. 2 is a timer macrotask, so it waits for the next loop tick.

A more practical example: long-running synchronous work blocks everything.

button.addEventListener('click', () => {
  for (let i = 0; i < 1e9; i++) {} // blocks ~1s
  console.log('done');
});

While the loop runs, clicks, animations, and timers all pile up. Nothing else happens until the handler returns. Splitting work across tasks is the usual fix:

function chunked(work, chunkSize = 5000) {
  let i = 0;
  function next() {
    const end = Math.min(i + chunkSize, work.length);
    while (i < end) doItem(work[i++]);
    if (i < work.length) setTimeout(next, 0);
  }
  next();
}

Each setTimeout(next, 0) yields back to the loop, letting it process other events between chunks.

Browser vs Node

The browser interleaves rendering between tasks; long microtask chains can starve paints. Node has additional phases (timers, pending callbacks, poll, check, close callbacks) and a slightly different process.nextTick queue that runs before normal microtasks.

// Node-specific
process.nextTick(() => console.log('nextTick'));
Promise.resolve().then(() => console.log('promise'));
// Output: nextTick, promise

process.nextTick is even higher priority than microtasks. Overusing it can starve the loop in Node.

Common pitfalls

  • Assuming setTimeout(fn, 0) runs “immediately.” It runs no sooner than the next macrotask, after all microtasks drain and after any clamp delays browsers apply.
  • Creating microtask loops. A Promise that resolves another Promise inside .then can starve macrotasks and paints if the chain is unbounded.
  • Blocking the main thread with heavy synchronous loops. The UI freezes until the function returns.
  • Treating async as if it created threads. async functions still run on the same single thread; the await keyword just yields control.

Best practices

  • Move heavy CPU work off the main thread with Web Workers (browser) or worker_threads (Node).
  • Yield with await new Promise(r => setTimeout(r)) inside long loops to let other events process.
  • Prefer queueMicrotask over Promise.resolve().then when you just want to defer a small piece of work; it avoids an allocation.
  • Profile with the browser performance panel. “Long tasks” over 50 ms are direct evidence of event loop blocking.

FAQ

Is the event loop part of JavaScript or the runtime? The runtime. The ECMAScript spec describes job queues; the actual loop is implemented by the host (browser, Node, Deno, Bun).

Why does my await seem to pause forever? Because the promise it is waiting on never resolves, or because something earlier is monopolizing the loop. Both look the same from the outside.

Are microtasks faster than macrotasks? They run sooner but are not “faster.” They cost the same to execute; they just have priority.

Does async/await create threads? No. It is syntactic sugar for promise chains; everything still runs on one thread.

Progress is saved locally to your browser.