Search IOCombats

Search challenges, guides, questions and articles

The JavaScript Event Loop, Fully Explained: Tasks, Microtasks, and Where Rendering Fits In
event loopmicrotasksbrowser internalsscheduler.yieldINPinterview prep

The JavaScript Event Loop, Fully Explained: Tasks, Microtasks, and Where Rendering Fits In

By Ghazi Khan | Oct 12, 2026 - 9 min read

Almost every frontend interview has a snippet with setTimeout, a Promise, and a few console.log calls, followed by the question "what is the output?". Most candidates memorize "microtasks before macrotasks" and stop there. That rule is correct, but it hides the real model, and the real model is what lets you answer the follow-ups: where does rendering happen, why can a Promise chain freeze the page while a setTimeout chain does not, and why does a 200ms click handler wreck your INP score.

This post builds the event loop from the ground up. By the end you will be able to trace any ordering puzzle by hand, and you will know which scheduling primitive to reach for in real code.

The setup: one thread, one call stack

JavaScript on a page runs on a single thread (the main thread). That thread has a call stack, which tracks which function is currently executing. Only one function runs at a time, and it runs to completion before anything else can run. There is no preemption: if your function takes 300ms, nothing else on the main thread happens for 300ms. No click handlers, no layout, no painting.

The browser still needs to handle timers, network responses, user input, and rendering. The event loop is the mechanism that coordinates all of that around the single call stack. It is a loop that repeatedly asks "what should the main thread do next?" and hands the answer to the call stack.

Task queues: where work waits

Work that is not currently on the call stack waits in a task queue. Each unit of work is called a task. These all become tasks:

  • Running a <script> the first time
  • A DOM event being dispatched (a click, a keypress, a message event)
  • A setTimeout or setInterval callback whose delay has elapsed
  • A network callback such as a fetch completing (the part that resolves the promise)

The spec does not describe one queue. It describes multiple task queues, grouped by task source (for example, user interaction, timers, networking). Within a single queue, tasks run in order. Across queues, the browser is free to choose which queue to service next. This is how browsers prioritize input over timers: they can pick the user-interaction queue first without breaking any ordering guarantee inside a queue.

Microtasks: the queue that jumps the line

There is a second, separate structure called the microtask queue. These things enqueue microtasks:

  • .then(), .catch(), .finally() callbacks on a settled promise
  • Code after an await (the continuation of an async function)
  • queueMicrotask(fn)
  • MutationObserver callbacks

The key rule is the microtask checkpoint. Whenever the call stack becomes empty after a task finishes, the browser runs every microtask in the queue, and it keeps going until the queue is completely empty, including any new microtasks queued by the microtasks themselves. Only then does the event loop move on.

The loop, written out

Here is the event loop processing model reduced to what matters for application code:

Diagram
100%
flowchart TD A["Pick a runnable task from a task queue"] --> B["Run it on the call stack until the stack is empty"] B --> C["Microtask checkpoint:<br/>run ALL microtasks until the queue is empty"] C --> D{"Is it a rendering opportunity?<br/>(roughly once per display refresh)"} D -- "No" --> A D -- "Yes" --> E["Update the rendering:<br/>rAF callbacks, style, layout,<br/>ResizeObserver, IntersectionObserver, paint"] E --> F["Idle time, if any:<br/>requestIdleCallback"] F --> A
visualized byIOCombats

Three details in this diagram carry most of the interview value:

  1. One task, then microtasks, then maybe a render. The loop does not run all timer callbacks and then render. It runs one task, drains microtasks, and then checks whether a frame is due.
  2. Rendering is not guaranteed after every task. The browser only updates the rendering when it is a rendering opportunity, which is tied to the display refresh rate. At 60Hz that is a budget of roughly 16.7ms per frame (1000 / 60). Many tasks can run between two frames.
  3. Rendering shares the thread with your code. Style recalculation, layout, and paint on the main thread cannot start while your task is still on the call stack. A long task directly delays the next frame.

Tracing an output puzzle by hand

Here is the classic snippet. Run it in any browser console:

console.log('A: sync start');

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

Promise.resolve().then(() => {
  console.log('C: promise 1');
  queueMicrotask(() => console.log('D: nested microtask'));
});

queueMicrotask(() => console.log('B2: queueMicrotask'));

console.log('B: sync end');

The output is:

A: sync start
B: sync end
C: promise 1
B2: queueMicrotask
D: nested microtask
E: timeout

(The labels are intentionally not alphabetical, because execution order is not alphabetical either.) Here is the trace:

Diagram
100%
sequenceDiagram participant Stack as Call Stack participant Micro as Microtask Queue participant Task as Task Queue (timers) Stack->>Stack: log "A", log "B" (current script task) Stack->>Task: setTimeout callback scheduled Stack->>Micro: promise reaction (C) queued Stack->>Micro: queueMicrotask (B2) queued Note over Stack: script task ends, stack empty Micro->>Stack: run C, which queues D at the back Micro->>Stack: run B2 Micro->>Stack: run D (queue still draining) Note over Micro: queue empty, checkpoint ends Task->>Stack: next task: timeout callback logs "E"
visualized byIOCombats

Notice that the D microtask, queued while the microtask queue was draining, still runs before the timeout. The checkpoint does not end until the queue is truly empty. That single fact explains the next section.

Why a promise loop freezes the page and a timer loop does not

Compare these two functions:

// Version 1: recursive microtask
function spinMicro() {
  queueMicrotask(spinMicro);
}

// Version 2: recursive timer
function spinTimer() {
  setTimeout(spinTimer, 0);
}

spinMicro() never lets the microtask queue empty, so the checkpoint never finishes. The event loop cannot reach the rendering step or pick the next task. The tab freezes completely, including scroll and clicks. This is called microtask starvation.

spinTimer() is harmless to responsiveness. Every iteration is a separate task, so between iterations the loop can service input and render frames. It also gets throttled: per the HTML standard, once timers are nested five levels deep, browsers enforce a minimum delay of about 4ms. Timers in background tabs are throttled much harder (Chrome limits them to once per second, or once per minute under intensive throttling).

This is also why await in a loop does not give the browser a chance to paint. Each await only schedules a microtask continuation, so the page can still be starved:

async function processAll(items) {
  for (const item of items) {
    heavyWork(item); // blocks the main thread
    await Promise.resolve(); // continuation is a microtask, NOT a task: no paint can happen here
  }
}

Where requestAnimationFrame fits

requestAnimationFrame (rAF) callbacks are not tasks and not microtasks. They run inside the "update the rendering" step, before style recalculation and layout for that frame. Two practical consequences:

  • DOM or style changes you make inside a rAF callback are included in that same frame, because style and layout run after the callbacks.
  • Reading layout values (like offsetHeight) right after writing styles forces the browser to compute layout synchronously, early, inside your callback. Batch all reads first, then all writes.

rAF is also paused in background tabs, so always compute animation progress from the timestamp argument and not from a frame counter:

let start;
function step(timestamp) {
  start ??= timestamp;
  const progress = Math.min((timestamp - start) / 1000, 1); // 1 second animation
  box.style.transform = `translateX(${progress * 200}px)`;
  if (progress < 1) requestAnimationFrame(step);
}
requestAnimationFrame(step);

Choosing the right primitive

PrimitiveQueue it usesRuns before the next paint?Use it for
Synchronous codeCurrent call stackBlocks the paintShort, necessary work
Promise.then, await, queueMicrotaskMicrotask queueYes, always, before any renderFinishing work that must complete before anything else observes state
requestAnimationFrameRendering stepYes, it runs just before itVisual updates tied to a frame
setTimeout(fn, 0)Timer task queueNot guaranteedDeferring work so the browser can render and handle input first
scheduler.yield()Task queue, continuation prioritizedGives the browser a chance to renderBreaking up long tasks without losing your place in line
requestIdleCallbackIdle periodAfter rendering, if time remainsLow-priority work such as analytics

Fixing a long task with scheduler.yield()

A long task is any task over 50ms. Interaction to Next Paint (INP) suffers because a click that arrives during a long task has to wait for the task to finish before its handler can even start, and then the next frame has to wait for the handler.

The standard fix is to split the work into chunks and give the browser a chance to run input handlers and render between chunks. The old way was setTimeout(resolve, 0). The problem is that the continuation goes to the back of the timer queue, behind other queued work. scheduler.yield() returns a promise that resolves in a new task, but the continuation is prioritized over other pending tasks of the same kind, so your work resumes sooner. At the time of writing it is available in Chromium-based browsers and recent Firefox, and Safari does not support it, so check current compatibility data and keep a fallback.

Here is a DRY version with a reusable helper and a time-sliced loop:

const yieldToMain = () =>
  globalThis.scheduler?.yield
    ? scheduler.yield()
    : new Promise((resolve) => setTimeout(resolve, 0));

async function processInChunks(items, handle, budgetMs = 8) {
  let sliceStart = performance.now();

  for (const item of items) {
    handle(item);

    if (performance.now() - sliceStart > budgetMs) {
      await yieldToMain(); // browser can now handle input and render
      sliceStart = performance.now(); // start a new time slice
    }
  }
}

// usage
processInChunks(rows, (row) => renderRow(row));

Yielding by time budget (here 8ms) is better than yielding every N items, because the cost per item varies. Staying under about 8ms per slice leaves room within a 16.7ms frame for style, layout, and paint.

Diagram
100%
flowchart LR subgraph Before["One 300ms task"] L["heavyWork for all items"] --> X["Input and paint wait 300ms"] end subgraph After["Chunked with yield"] C1["8ms chunk"] --> Y1["yield: input and paint can run"] --> C2["8ms chunk"] --> Y2["yield: input and paint can run"] --> C3["..."] end
visualized byIOCombats

Practical takeaway

For interviews, practice this routine on every ordering question: (1) run all synchronous code, (2) when the stack empties, drain the entire microtask queue including newly added microtasks, (3) if a frame is due, run rAF callbacks then render, (4) pick the next task. Say the steps aloud while you trace; interviewers care about the model more than the final answer. Be ready to explain microtask starvation and why await alone never lets the page paint.

For real work, find long tasks in the Chrome DevTools Performance panel (they are flagged with a red corner), then break them up with a time-sliced loop and scheduler.yield() with a setTimeout fallback. Use rAF for visual updates, never for general scheduling, and keep promise chains short when they wrap heavy synchronous work.

Conclusion

The event loop is a simple cycle with one important asymmetry: microtasks drain completely after every task, while rendering only happens when a frame is due and your code is not running. Once you hold that model, ordering puzzles become mechanical, and INP problems become a question of how long each of your tasks holds the thread.

Advertisement

Ready to practice?

Test your skills with our interactive UI challenges and build your portfolio.

Start Coding Challenge