The event loop explained with zero confusion

Posted by

A developer-first deep dive into tasks, microtasks, rendering, and Node.js phases — with clean examples, real-world bugs, and copy‑paste fixes.

A developer-first deep dive into tasks, microtasks, rendering, and Node.js phases — with clean examples, real-world bugs, and copy‑paste fixes.

Introduction

If you’ve ever wondered why a setTimeout(0) still runs “late,” why Promise handlers jump the queue, or why your React setState doesn’t update “right away,” you’re already negotiating with the event loop. The loop is the traffic cop of JavaScript’s runtime. Get it, and async code stops feeling mysterious — your apps get snappier, your bugs get rarer, and your mental model tightens.

This guide is not a formal spec tour. It’s the practical, no-hand-waving version. We’ll map exactly what runs when (and why), show how browsers and Node differ, decode microtasks vs. macrotasks with crisp examples, and give you patterns for real apps: React, Node services, and performance-sensitive UIs. By the end, you’ll be able to predict execution order with confidence — zero confusion.


The big picture mental model

Before the weeds, let’s set the stage. JavaScript (in browsers or Node) runs inside a runtime that provides APIs, queues, and a scheduler wrapped around the engine.

  • Engine: Executes JS code (e.g., V8 in Chrome/Node), managing the call stack and heap.
  • Host environment: Browser or Node provides async capabilities (timers, I/O, DOM, fetch, fs, etc.).
  • Scheduler pieces: Event loop, microtask queue, task queues, rendering pipeline (browser), and Node’s I/O phases.

Think of it like a restaurant:

  • Kitchen (engine): Cooks exactly one dish at a time (single thread).
  • Waitlist (queues): Orders waiting for kitchen time — some VIP (microtasks), some regular (tasks).
  • Front-of-house (host APIs): Timers, network, DOM, file I/O — they don’t cook; they just stage dishes to be cooked.
  • Manager (event loop): Decides which order hits the kitchen next, with strict rules.

How execution actually flows

At runtime, there’s a repeatable loop:

  1. Run JS until the call stack is empty.
  2. Drain all microtasks (Promise reactions, queueMicrotask, MutationObserver).
  3. In browsers, optionally render if needed and time allows.
  4. Take the next task from the appropriate queue (e.g., timers) and push its callback to the call stack.
  5. Repeat forever.

What sits where

  • Call stack: The function currently executing and its chain.
  • Heap: Objects, closures, arrays — the long-lived stuff.
  • Task queues: Macrotasks like setTimeout, setInterval, message events, postMessage, I/O callbacks, setImmediate (Node).
  • Microtask queue: Promise then/catch/finally, queueMicrotask, MutationObserver callbacks.
  • Rendering pipeline (browser): Style → layout → paint → composite; coordinated around frames (ideally 60fps).

Tasks vs. microtasks: the core rule

The single most important rule: After the call stack empties, the runtime drains the entire microtask queue before running the next task. This is why Promise handlers often “jump ahead” of timers, even with setTimeout(fn, 0).

Minimal example

console.log('A');
setTimeout(() => console.log('B (timeout)'), 0);
Promise.resolve().then(() => console.log('C (microtask)'));
console.log('D');

Expected output:

A
D
C (microtask)
B (timeout)
  • Why: Synchronous logs run first (A, D). When the stack empties, the event loop drains microtasks © before pulling the next task (B).

What counts as microtasks

  • Promise reactions: then/catch/finally.
  • queueMicrotask: A direct way to enqueue a microtask.
  • MutationObserver: Browser-only microtask for DOM change observation.

What counts as tasks (macrotasks)

  • Timers: setTimeout, setInterval (browser & Node).
  • MessageChannel/postMessage: Browser task.
  • I/O callbacks: Network, fs (Node), and certain DOM events (browser).
  • setImmediate: Node’s “check” phase task.
  • UI events: Clicks, keypress, etc., typically scheduled as tasks.

Browsers vs. Node.js: same idea, different beats

Both use tasks and microtasks, but Node adds well-defined phases and its own quirks.

Browser ordering essentials

  • Microtasks drain after each task and before rendering.
  • Rendering frames typically occur between tasks when the microtask queue is empty.
  • requestAnimationFrame (rAF) callbacks run before the next paint, after microtasks, but before rendering.
  • requestIdleCallback (rIC) runs when the browser is idle, not guaranteed per frame.

Node.js event loop phases (simplified)

In each tick, Node goes through phases roughly like:

  1. Timers (setTimeout/setInterval)
  2. Pending callbacks (system-level)
  3. Idle, prepare (internal)
  4. Poll (I/O callbacks; can block to wait for I/O)
  5. Check (setImmediate callbacks)
  6. Close callbacks (e.g., socket close)
  • Microtasks (Promises) drain after each callback completes, before the loop moves phases.
  • process.nextTick callbacks run immediately after the current function finishes, even before Promises, and can starve I/O if abused.

setImmediate vs setTimeout(fn, 0) in Node

setTimeout(() => console.log('timeout 0'), 0);
setImmediate(() => console.log('immediate'));
  • If I/O just finished: setImmediate usually fires first (check phase runs after poll).
  • Without prior I/O: ordering between timeout(0) and immediate can vary; don’t rely on a deterministic order.

process.nextTick is special

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

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

process.nextTick(() => console.log('nextTick (ultra-micro)'));

Typical output in Node:

nextTick (ultra-micro)
promise microtask
timeout
  • Rule of thumb: nextTick runs before Promise microtasks. Overuse can starve the loop.

Rendering, frames, and timing in the browser

The browser tries to render ~60fps (every ~16.7ms), but only when it can. Your code can help or hinder it.

Frame lifecycle (conceptual)

  1. Run a task (e.g., your event handler).
  2. Drain microtasks.
  3. Run rAF callbacks (give you a chance to measure DOM before paint).
  4. Layout, paint, composite.
  5. Maybe run rIC if time remains.

Practical implications

  • Use rAF for smooth animations; it pairs with paints.
  • Use microtasks for quick follow-up work that must happen “now” before the browser does anything else.
  • Use setTimeout when you want to let rendering happen first, then run your callback afterward.

Example: enforce an update before painting

btn.addEventListener('click', () => {
box.classList.add('highlight');
// Force immediate follow-up after DOM change, before paint.
queueMicrotask(() => {
// Read something after DOM class change if needed.
});
// Or ask for next frame work:
requestAnimationFrame(() => {
// Do layout-sensitive work aligned with paint.
});
});
  • Why: Microtask happens before the next task and before rAF; rAF aligns to the next frame and is ideal for animations or layout reads/writes tied to paint.

setTimeout(0) isn’t immediate (and why that’s good)

Even with a 0ms delay, timers run as tasks — after microtasks and after the current task finishes.

Minimum timer clamping

  • Inactive tabs: Browsers clamp timers to bigger intervals (e.g., 1000ms+).
  • Nested timers: Old spec behavior used to clamp increments; modern browsers are more consistent, but don’t assume perfect precision.
  • Power-saving modes: Expect real-world jitter; timers are best-effort, not real-time.

Prefer explicit intent

  • queueMicrotask: “Do it right after the current call.”
  • requestAnimationFrame: “Do it before the next paint.”
  • setTimeout(fn, 0): “Do it sometime soon after giving the loop a breath.”

Async/await under the hood

async/await is syntax over Promises. The “await” keyword pauses the async function, but it doesn’t block the thread; it schedules a continuation as a microtask once the awaited Promise settles.

(async function run() {
console.log('A');
await null; // treated like Promise.resolve(null)
console.log('B'); // microtask after the current stack empties
})();
console.log('C');

Output:

A
C
B
  • Why: The code after await runs in a microtask continuation. Synchronous code © finishes first.

Handling errors

  • Rejections in async functions throw into the function context; unhandled rejections can be fatal (Node) or noisy (browser).
  • Always attach a final catch on top-level async calls or use global handlers in dev to detect leaks.

Real-world scenarios and patterns

React: batching, microtasks, and state timing

  • State updates are batched during React’s controlled events and flushed later (often in microtasks or scheduled work).
  • Don’t expect immediate DOM updates after setState; use effects or layout effects to observe results.
  • Avoid heavy synchronous work in event handlers; you’ll block paints and input responsiveness.

Debounce vs. microtask “coalescing”

  • Microtask coalescing: Aggregate multiple synchronous triggers into a single microtask tick.
let pending = false;

function scheduleOnce(fn) {
if (pending) return;
pending = true;
queueMicrotask(() => {
pending = false;
fn();
});
}
// Example: collapse multiple updates into one flush
let buffer = [];
function logBuffered(value) {
buffer.push(value);
scheduleOnce(() => {
console.log('Flushed:', buffer.join(', '));
buffer = [];
});
}
logBuffered(1);
logBuffered(2);
logBuffered(3);
// Output (once): Flushed: 1, 2, 3
  • When to use: Coalesce multiple sync updates before yielding control — useful for quick batches that must happen “now.”
  • When to avoid: If work is heavy, use tasks or split across frames to avoid blocking rendering.

UI responsiveness: yielding control

  • Long loops freeze the UI (single thread, remember).
  • Chunk work and yield between chunks to keep the app responsive.
async function processLargeList<T>(
items: T[],
chunkSize = 500
) {
for (let i = 0; i < items.length; i += chunkSize) {
const chunk = items.slice(i, i + chunkSize);
// Do heavy work for this chunk...
doWork(chunk);
// Yield to the event loop and allow rendering.
await new Promise(requestAnimationFrame); // next frame
}
}
  • Alternative yields: setTimeout(resolve, 0) (task), or await Promise.resolve() (microtask, not great if rendering is needed).

Node services: nextTick and Promise starvation

  • Avoid piling nextTick in loops; it runs before I/O, starving the poll phase.
  • Prefer Promise microtasks for “soon” scheduling; prefer setImmediate for “after I/O” scheduling.
// Bad: can starve I/O
function tightLoop(n) {
if (n === 0) return;
process.nextTick(() => tightLoop(n - 1)); // yikes
}
// Better: let I/O breathe
function friendlierLoop(n) {
if (n === 0) return;
if (n % 1000 === 0) {
setImmediate(() => friendlierLoop(n - 1)); // after poll
} else {
Promise.resolve().then(() => friendlierLoop(n - 1));
}
}

Scheduling for paint: animation and input

  • rAF before paint for animation steps.
  • rIC for low-priority tasks when there’s idle time.
  • Pointer/mouse events: Do the bare minimum; schedule heavier work via rAF or setTimeout to keep input smooth.

Common pitfalls and how to fix them

Starving the event loop with microtasks:

  • Symptom: UI never repaints; CPU pinned; Node I/O delays.
  • Fix: Break chains with tasks (setTimeout/rAF) or slice work.

Assuming setTimeout(0) is first:

  • Symptom: Promise handlers run earlier than expected.
  • Fix: Use queueMicrotask if you need immediate microtask behavior.

Unbounded Promise chains:

  • Symptom: Memory ballooning, long microtask drain.
  • Fix: Cap chain lengths; interleave tasks; refactor to generators or streams.

Heavy synchronous handlers:

  • Symptom: Janky scrolling, input lag.
  • Fix: Move work off the hot path (rAF, web workers, break into chunks).

Misusing process.nextTick in Node:

  • Symptom: I/O starvation, high latency under load.
  • Fix: Prefer Promise microtasks; reserve nextTick for rare immediate follow-ups (API invariants).

Unhandled promise rejections:

  • Symptom: Crashes in Node, swallowed errors in browsers.
  • Fix: Add top-level catch, global handlers in dev, and reject pipelines with care.

Code patterns you’ll actually reuse

Microtask vs task utilities

export const microtask = () => new Promise<void>(queueMicrotask);
export const nextTask = () => new Promise<void>(r => setTimeout(r, 0));
export const nextFrame = () => new Promise<number>(requestAnimationFrame);
  • microtask: Continue immediately after the current stack.
  • nextTask: Yield to the loop; allow rendering and other tasks first.
  • nextFrame: Align work with the next paint.

Safe async handler wrapper (browser)

function safeAsync<T extends any[]>(
handler: (...args: T) => Promise<void>
) {
return (...args: T) => {
handler(...args).catch(err => {
// Report in dev; surface to your logging infra in prod.
console.error('Unhandled async error:', err);
});
};
}
  • Where to use: Event listeners, React handlers (if not automatically handled), DOM callbacks.

Timeout wrapper with AbortController

export async function withTimeout<T>(
p: Promise<T>,
ms: number,
signal?: AbortSignal
): Promise<T> {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), ms);

try {
const res = await Promise.race([
p,
new Promise<T>((_, reject) =>
controller.signal.addEventListener('abort', () => reject(new Error('Timeout')))
),
]);
return res;
} finally {
clearTimeout(timeout);
signal?.addEventListener('abort', () => controller.abort());
}
}
  • Why: Scheduling timeouts is part of event loop coordination. Use race + timers to bound latency.

Yielding inside expensive loops (browser)

async function yieldyForEach<T>(
items: T[],
fn: (item: T, i: number) => void,
budgetMs = 8
) {
let start = performance.now();
for (let i = 0; i < items.length; i++) {
fn(items[i], i);
if (performance.now() - start >= budgetMs) {
await new Promise(requestAnimationFrame); // yield to paint
start = performance.now();
}
}
}
  • Why: Keep frames under budget, maintain 60fps.

Subtle ordering: rAF, microtasks, and mutation observers

  • Microtasks run before rAF: If you queue a microtask inside an event, it will finish before any rAF callbacks fire for the next frame.
  • MutationObserver is a microtask: DOM mutations observed in the same tick, before paint.
  • rAF is perfect for measuring layout after DOM updates: It runs right before painting.
el.classList.add('enter');

queueMicrotask(() => {
// Runs before rAF, still same tick.
});
requestAnimationFrame(() => {
// Measure layout here; class is applied, styles calculated next.
const rect = el.getBoundingClientRect();
console.log(rect);
});

Node internals you should keep straight

  • Timers phase: Executes callbacks whose timers have expired.
  • Poll phase: Retrieves new I/O events; can block for I/O.
  • Check phase: setImmediate callbacks.
  • Microtasks: Drain after each callback (Promises), and nextTick between any step and even before Promises.

Practical Node scheduling rules

  • Use setImmediate to run after I/O callbacks in the same tick.
  • Use setTimeout(0) to run “soon,” but not necessarily after poll; timing depends on how long phases take.
  • Use Promises for “after this callback completes” scheduling.
  • Avoid recursive nextTick; reserve it for invariant enforcement just after the current stack unwinds.

Debugging the event loop

Chrome DevTools Performance panel (browser):

  • What to do: Record a timeline; look for “Long tasks,” layout/paint bars, and promise microtasks.
  • What to fix: Split long tasks; move layout thrash to rAF; batch microtasks.

Node.js diagnostics (CLI and DevTools):

  • What to do: Use the inspector (node — inspect), record CPU profiles; watch for heavy JS frames and promise microtask churn.
  • What to fix: Yield between CPU-heavy chunks (setImmediate), reduce nextTick usage, stream I/O.

Unhandled promise traps:

  • Browser: window.addEventListener(‘unhandledrejection’, …) in dev.
  • Node: process.on(‘unhandledRejection’, …), process.on(‘rejectionHandled’, …).

A scheduling checklist you can tape to your monitor

Need to run immediately after current sync code?

  • Use: queueMicrotask or Promise.resolve().then.

Need to let rendering or I/O proceed first?

  • Use: setTimeout(fn, 0) or setImmediate (Node).

Need to animate or measure/modify layout?

  • Use: requestAnimationFrame (and requestIdleCallback for low-priority).

Need to avoid starving the loop under load?

  • Use: Chunk work; yield via rAF or setImmediate; avoid recursive microtasks/nextTick.

Need deterministic “after I/O” in Node?

  • Use: setImmediate.

Need to coalesce multiple sync triggers into one micro-batch?

  • Use: queueMicrotask with a “pending” flag.

Frequently confusing cases, clarified fast

Why does Promise.then run before setTimeout(0)?

  • Because: Microtasks drain before the next task.

If I queue microtasks in a loop, will rendering happen?

  • No: The browser won’t render until microtasks finish; you can starve the frame.

Is async/await “blocking”?

  • No: It pauses the async function and schedules the continuation as a microtask.

In Node, why did setImmediate fire before setTimeout(0)?

  • Because: After I/O, the check phase (setImmediate) runs before timers in the next tick.

Why does my setTimeout(16) not align perfectly with 60fps?

  • Because: Timers aren’t synchronized with the rendering pipeline; use rAF.

Conclusion

You don’t need to memorize the spec to master async behavior — you need the right mental model and a few dependable rules:

  • Drain order matters: Stack → microtasks → next task, with browser rendering fitted between task turns.
  • Microtasks are VIPs: Promise handlers and queueMicrotask run before any new task or paint.
  • Choose the right lane: rAF for frames, microtasks for immediate follow-ups, tasks for yielding and I/O coordination.
  • Node phases are practical: setImmediate for post-I/O, prefer Promises over nextTick, and never starve the poll phase.
  • Yield early and often: Split heavy work; keep the UI responsive and services low-latency.

When you can predict the event loop, bugs become obvious, performance tuning becomes surgical, and async code becomes a tool, not a trap.



Pro tip

If you’re debugging “why didn’t the DOM update yet?” add this one-liner temporarily after your state change:

await Promise.resolve();

That single microtask often clarifies whether your issue is sync work vs. microtask timing vs. needing to wait for the next paint (in which case, switch to await new Promise(requestAnimationFrame)).


Call to action

If this cleared up even one “mystery ordering” bug in your head, drop a quick example in the comments — what tripped you up, and how you’ll schedule it now. Share this with a teammate who’s knee-deep in async code, and bookmark it for the next time setTimeout(0) surprises you.

Leave a Reply

Your email address will not be published. Required fields are marked *