The problem the loop solves

JavaScript is single-threaded. There is exactly one thread that runs your code, and while it is running a function, no other JavaScript runs on that thread. That should sound like a problem: a single network request, a single image load, a single slow handler would freeze the entire page or the entire server.

It does not freeze, because JavaScript does not actually do network requests, image loading, or timers. It asks the host environment to do them, and the host (the browser or Node.js) hands the results back as callbacks queued for later. The event loop is the mechanism that pulls those queued callbacks back into the single thread when it is idle.

The whole article is an unpacking of that one paragraph.

JavaScript is single-threaded. The runtime is not.- the practical heuristic

The engine: heap and call stack

The JavaScript engine (V8 in Chrome and Node, JavaScriptCore in Safari, SpiderMonkey in Firefox) has two main runtime structures:

  • The heap. Where objects and closures live. Garbage-collected. Not interesting for understanding the loop.
  • The call stack. The stack of currently-executing function frames. When you call a function, a frame goes on. When the function returns, the frame comes off. When the stack is empty, no JavaScript is running.

The call stack is the part that matters. While a frame is on the stack, that function is in the middle of executing - and no other JavaScript can interrupt it. That's the "single-threaded" part.

// While bar() runs, foo() is mid-execution above it on the stack
function foo() {
  console.log("foo start");
  bar();           // bar() pushed on top of foo()
  console.log("foo end");
}

function bar() {
  console.log("bar");    // stack: [foo, bar]
}                       // bar returns; stack: [foo]

foo();                  // stack starts empty, push foo, push bar, ...

So far so synchronous. Where does the loop come in? When a function asks the host to do something asynchronous.

The host: Web APIs and Node APIs

JavaScript does not define a way to make a network request, schedule a timer, or query the DOM. Those are provided by the host environment - the browser exposes Web APIs, Node.js exposes its own set built on libuv. From JavaScript's perspective, both look the same: you call a host function, hand it a callback, and the host promises to call the callback later.

  • setTimeout(cb, ms) - "wait ms milliseconds, then schedule cb." The waiting happens in the host, off the JavaScript thread.
  • fetch(url) - "make an HTTP request, resolve a promise when the response arrives." The HTTP work happens in the host, off the JavaScript thread.
  • element.addEventListener("click", cb) - "when the user clicks this element, schedule cb." Event dispatch happens in the host.
  • Node equivalents - fs.readFile, setImmediate, network sockets - all of these are libuv work scheduled off-thread.

Critical point: when setTimeout(cb, 1000) returns, cb has not been called. The host has just registered a future intent. The JavaScript thread is free to keep running. A thousand milliseconds later, the host pushes cb into a queue.

Task queue and microtask queue

The diagram up top shows a single "callback queue." In real specifications it's two queues with different priority - and the difference between them is responsible for an entire category of bug.

  • The task queue (also called the macrotask queue or callback queue). This is where setTimeout, setInterval, I/O callbacks, and event handlers land. The event loop processes one task per iteration.
  • The microtask queue. This is where promise continuations land - the callbacks you pass to .then(), the body after await, and explicit queueMicrotask() calls. The event loop drains the microtask queue completely after every task, before pulling the next task.

This is why a promise resolution always runs before a setTimeout(_, 0) scheduled at the same moment - the microtask queue is drained between them:

console.log("1");

setTimeout(() => console.log("2 - timeout"), 0);

Promise.resolve().then(() => console.log("3 - promise"));

console.log("4");

// Output:
// 1
// 4
// 3 - promise   ← microtask drained first
// 2 - timeout   ← then the next task

The microtask queue is a sharp tool. A microtask can schedule another microtask, which can schedule another - and the loop will keep draining until the queue is empty before moving on. That means you can starve the event loop with an infinite chain of microtasks and freeze the page despite never blocking the call stack synchronously.

The event loop algorithm

Stripped to its bones, the event loop is this:

// pseudocode
while (true) {
  // 1. Take one task from the task queue (if any) and run it to completion.
  const task = taskQueue.dequeue();
  if (task) runToCompletion(task);

  // 2. Drain the microtask queue completely.
  while (microtaskQueue.hasItems()) {
    runToCompletion(microtaskQueue.dequeue());
  }

  // 3. (Browser only) If a render is due, render. Then loop.
  if (shouldRender()) render();
}

Three things to notice:

  • "Run to completion" is the contract. Once a task starts running, it runs until its synchronous code finishes. The loop does not pre-empt. If your task takes 500ms, the page is frozen for 500ms.
  • Microtasks run between tasks, not between functions. A long task with a thousand promise resolutions chained inside will not yield to the browser - it will just drain microtasks at the end of itself.
  • Rendering is a host step. In a browser, the rendering pass runs between tasks (and only if the frame budget allows). If you tie up the loop with tasks faster than the browser can render, the page stops updating - the famous "JavaScript stalled the page" symptom.

A worked example

Trace through this carefully:

console.log("A");

setTimeout(() => {
  console.log("B");
  Promise.resolve().then(() => console.log("C"));
}, 0);

Promise.resolve().then(() => {
  console.log("D");
  setTimeout(() => console.log("E"), 0);
});

console.log("F");

// Output:  A  F  D  B  C  E

Walk through it:

  • Synchronous pass. Prints A. Schedules a timeout task for "B...". Schedules a microtask for "D...". Prints F. Call stack drains.
  • Microtasks drain. Run the "D..." microtask: prints D, schedules a new timeout task for E. Microtask queue is now empty.
  • Next task. Pull the first timeout task. Prints B, schedules a microtask for C.
  • Microtasks drain. Run "C": prints C.
  • Next task. Pull the E timeout (scheduled inside D). Prints E.

If your prediction was different, the most common mistake is treating setTimeout(_, 0) as if it ran immediately. It doesn't - it runs on the next task tick, which is always after the current call stack is empty and after the current microtask queue is drained.

Practical implications and pitfalls

  • Long synchronous work freezes the page. A loop over a million items, a heavy JSON parse, a synchronous regex - all of them block the call stack and stop the loop. Move expensive work to a Web Worker, chunk it across tasks with setTimeout(_, 0) or requestIdleCallback, or stream it.
  • Microtask starvation is real. A chain of .then()s that never yields to a task can lock out rendering. If you build a recursive microtask loop, insert a real task between iterations.
  • "setTimeout(_, 0)" is not really zero. Browsers clamp nested timeouts to a minimum (~4ms in many engines after some nesting), and the actual delay depends on how busy the queue is. For "yield then continue," queueMicrotask or a promise resolution is more reliable - but only inside the same task.
  • await is microtask sugar. Code after await x runs as a microtask scheduled when x settles. Two awaits in a row interleave with microtask drain, not with task ticks. This matters when you're sequencing work that needs to yield to the page.
  • Node has its own quirks. Node's loop adds phases (timers, I/O, immediates, close callbacks) and the order between setTimeout(_, 0) and setImmediate(_) depends on context. The principles are the same; the priorities differ.
  • The DOM is not in the engine. When you call document.querySelector, you are calling into a host API. The result comes back synchronously, but the data structure itself lives on the host side. Heavy DOM work counts as a task; chunk it.

The rule of thumb that survives every refactor: treat the call stack as a shared resource. Anything you do on it costs the rest of the program time. If a job is bigger than a frame budget (~16ms for 60fps), the right move is almost always to break it into smaller tasks.