Understanding JavaScript’s Event Loop

Understanding JavaScript's Event Loop

Of everything I’ve learned about JavaScript, the event loop is the concept that most transformed how I debug asynchronous code. I used to just accept “async stuff happens later” without understanding exactly when “later” was. Once I traced through the event loop mechanics carefully, timing bugs that used to feel random became completely predictable. This article is my attempt to give you that same clarity.

Why the Event Loop Exists

JavaScript runs on a single thread — one call stack, one thing executing at a time. But browsers and Node.js need to handle things like timers, network requests, and user input without freezing everything else while waiting. The event loop is the mechanism that reconciles these two facts: a single-threaded language handling seemingly concurrent operations.

The Core Pieces

1. The Call Stack

The call stack tracks function calls. When a function is invoked, it’s pushed on; when it returns, it’s popped off.

function a() { b(); }
function b() { c(); }
function c() { console.log("c running"); }

a();
// Stack grows: a -> b -> c
// Then unwinds: c pops, b pops, a pops

2. Web APIs / Node APIs

Asynchronous operations like setTimeout, fetch, DOM events, and file I/O aren’t handled by the JavaScript engine itself — they’re delegated to the surrounding runtime environment (the browser’s C++ APIs, or Node’s libuv), which handles the actual waiting outside the single JS thread.

3. The Microtask Queue

Holds callbacks from Promises (.then, .catch, .finally), queueMicrotask(), and async/await continuations. This queue has higher priority than the macrotask queue.

4. The Macrotask Queue (Task Queue)

Holds callbacks from setTimeout, setInterval, I/O events, and UI rendering-related tasks. Only one macrotask is processed per event loop iteration.

The Event Loop Algorithm

At a conceptual level, the event loop repeatedly does this:

  1. Execute everything currently on the call stack until it’s empty.
  2. Once the stack is empty, process the entire microtask queue, one at a time, until it’s completely empty — including any new microtasks added during this processing.
  3. Only after the microtask queue is fully drained, take one task from the macrotask queue, push it onto the stack, and run it.
  4. Optionally perform rendering (in browsers).
  5. Repeat from step 2.

This “microtask queue must be fully empty before the next macrotask” rule is the single most important detail for predicting execution order correctly.

Walking Through a Classic Example

console.log("1: Script start");

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

Promise.resolve()
  .then(() => console.log("3: Promise 1"))
  .then(() => console.log("4: Promise 2"));

console.log("5: Script end");

Output:

1: Script start
5: Script end
3: Promise 1
4: Promise 2
2: setTimeout

Step by step:

  1. "1: Script start" runs synchronously.
  2. setTimeout registers its callback with the browser’s timer API and returns immediately — the callback is scheduled as a macrotask once the delay elapses.
  3. Promise.resolve().then(...) schedules its callback as a microtask.
  4. "5: Script end" runs synchronously — the main script finishes, and the call stack is now empty.
  5. The event loop checks the microtask queue: it finds "3: Promise 1", runs it, which itself schedules "4: Promise 2" as another microtask — since the microtask queue isn’t considered “empty” until nothing new gets added, "4: Promise 2" also runs before moving on.
  6. Only now, with the microtask queue fully drained, does the event loop take the next macrotask — the setTimeout callback — and run "2: setTimeout".

Async/Await and the Event Loop

async/await is built directly on Promises, so it uses the exact same microtask queue.

console.log("A");

async function demo() {
  console.log("B");
  await null;
  console.log("C");
}

demo();
console.log("D");

Output: A, B, D, C

The function runs synchronously up to the await, at which point it suspends and schedules its continuation (console.log("C")) as a microtask, letting the caller’s synchronous code (console.log("D")) run first.

Rendering and the Event Loop (Browser-Specific)

In browsers, the event loop also coordinates with rendering. After the microtask queue is drained (and typically after each macrotask), the browser may perform a rendering step — recalculating styles, layout, and painting — if enough time has passed and there’s something new to display. This is why long-running microtask chains (like an infinite chain of .then() calls) can actually block rendering entirely, since the browser won’t get a chance to paint until the microtask queue is empty.

function infiniteMicrotasks() {
  Promise.resolve().then(infiniteMicrotasks); // never lets the microtask queue empty!
}
infiniteMicrotasks();
// This can freeze the UI, since rendering never gets a chance to happen

I’ve genuinely seen this pattern accidentally cause a frozen page in production code — a recursive .then() chain with no exit condition.

requestAnimationFrame vs. setTimeout

function animate() {
  console.log("frame");
  requestAnimationFrame(animate);
}
requestAnimationFrame(animate);

requestAnimationFrame schedules its callback to run right before the browser’s next repaint, synced to the display’s refresh rate (usually ~60fps) — I use this instead of setTimeout for any visual animation, since it’s optimized specifically for smooth rendering and automatically pauses in inactive/background tabs, saving battery and CPU.

Node.js: Event Loop Phases

Node.js implements a more elaborate event loop with distinct phases, each with its own queue.

PhasePurpose
TimersRuns setTimeout/setInterval callbacks whose timer has expired
Pending callbacksExecutes I/O callbacks deferred from the previous cycle
Idle, prepareInternal use
PollRetrieves new I/O events, executes I/O callbacks
CheckExecutes setImmediate() callbacks
Close callbackse.g., socket.on('close')

Additionally, process.nextTick() has its own queue that runs before the microtask (Promise) queue, at the end of every phase — making it even higher priority than Promises in Node.js specifically.

console.log("start");

setTimeout(() => console.log("timeout"), 0);
setImmediate(() => console.log("immediate"));
process.nextTick(() => console.log("nextTick"));
Promise.resolve().then(() => console.log("promise"));

console.log("end");

// Typical output:
// start
// end
// nextTick
// promise
// timeout (or immediate — order between these two can vary depending on context)
// immediate (or timeout)

I use process.nextTick() sparingly and deliberately, since overusing it can starve I/O callbacks from ever running if recursive nextTick calls keep the queue perpetually non-empty.

Internal Working: Why Microtasks Take Priority

The ECMAScript specification defines Promise reactions as jobs that must be processed as part of a “microtask checkpoint” — a required step the host environment (browser or Node.js) must perform whenever the JavaScript call stack becomes empty, before yielding control back to the event loop’s outer mechanisms like timers or I/O. This isn’t an implementation detail specific to one engine — it’s mandated by the spec itself, which is why this microtask-before-macrotask ordering is consistent across all standards-compliant JavaScript environments.

Practical, Real-World Applications

Avoiding UI Freezes with Chunked Processing

function processLargeArray(items, chunkSize = 100) {
  let index = 0;

  function processChunk() {
    const end = Math.min(index + chunkSize, items.length);
    for (; index < end; index++) {
      // process items[index]
    }
    if (index < items.length) {
      setTimeout(processChunk, 0); // yield back to the event loop between chunks
    }
  }

  processChunk();
}

By using setTimeout(fn, 0) between chunks, I let the event loop process other pending tasks (like user input or rendering) between batches, instead of blocking the entire thread until all items are processed.

Debugging Execution Order

console.log("1");
setTimeout(() => console.log("2"), 0);
queueMicrotask(() => console.log("3"));
Promise.resolve().then(() => console.log("4"));
console.log("5");

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

queueMicrotask() gives me direct, explicit access to the microtask queue without needing to wrap something in a Promise — useful when I want microtask timing without the overhead of Promise semantics.

Best Practices

  • I avoid long, blocking synchronous code, since it freezes the entire thread, including rendering and user interaction.
  • I use requestAnimationFrame for visual animations instead of setTimeout.
  • I break up large synchronous workloads into chunks using setTimeout or requestIdleCallback to avoid freezing the UI.
  • I’m cautious with recursive .then() chains or process.nextTick() calls that could starve the event loop.

Common Mistakes to Avoid

  1. Assuming setTimeout(fn, 0) runs immediately — it always waits for the current call stack and the entire microtask queue to clear first, at minimum.
  2. Writing recursive microtask chains with no exit, which can block rendering entirely in browsers.
  3. Overusing process.nextTick() in Node.js, potentially starving I/O.
  4. Confusing concurrency (interleaving tasks) with parallelism (simultaneous execution) — JavaScript’s event loop provides the former, not the latter, without Workers.

Debugging Tips

  • I use the Performance tab in Chrome DevTools to visually inspect the call stack, microtasks, and macrotasks over time.
  • I add sequential console.log statements with labels when execution order is unclear, then reason through the microtask/macrotask rules step by step.
  • In Node.js, I use --trace-sync-io to catch accidental blocking I/O calls that stall the event loop.

FAQs

Q: Does setTimeout(fn, 0) run before or after Promise callbacks? A: Always after — Promise callbacks are microtasks and are fully processed before the next macrotask, regardless of the requested timer delay.

Q: Can the event loop run two callbacks at the exact same time? A: No, JavaScript’s single-threaded model means only one callback runs at any given instant, even though multiple asynchronous operations are “in flight” via the runtime.

Q: What’s the difference between the microtask queue and the macrotask queue? A: Microtasks (Promises, queueMicrotask) are fully drained before the event loop processes the next macrotask (setTimeout, events, I/O), which only processes one item per event loop iteration.

Q: Why is process.nextTick() considered even higher priority than Promises in Node.js? A: Because Node.js processes the entire nextTick queue before it even begins processing the microtask (Promise) queue, at the end of each event loop phase.

Summary and Key Takeaways

  • JavaScript is single-threaded, but the event loop coordinates the call stack, Web/Node APIs, and task queues to enable non-blocking asynchronous behavior.
  • The microtask queue (Promises, async/await continuations) is fully drained before the macrotask queue (setTimeout, events) is touched.
  • Node.js has additional phases and a separate, even-higher-priority process.nextTick() queue.
  • Long, blocking synchronous code or endless microtask chains can freeze rendering and the entire application.
  • Understanding this model precisely — not just “async runs later” — makes previously confusing timing bugs completely predictable.

Once I could trace through exactly what the event loop does, step by step, debugging async timing issues stopped being guesswork and became a process I could reason through with confidence every time.

References

Total
0
Shares

Leave a Reply

Previous Post
10-Essential-JavaScript-Tips-and-Tricks

10 Essential JavaScript Tips and Tricks

Next Post
JavaScript-Basics-Variables-Data-Types-and-Operators

JavaScript Basics: Variables, Data Types, and Operators

Related Posts