Pular para o conteúdo
Clinford Jordano Valencia
Todos os artigos

JavaScript

The Event Loop Is Not What You Think: Tasks, Microtasks and Order

How the call stack, task queue, microtask queue and rendering fit together, how to predict execution order, and why a busy loop or a promise chain can freeze the page.

  • 10 min de leitura

Esta página ainda não está disponível em português, então você está lendo o original em inglês.

JavaScript runs your code on one thread, and the event loop decides what runs next. The model fits in one sentence: run a task to completion, run all queued microtasks, maybe render, repeat. Timers and events are tasks; promise callbacks are microtasks. Once you know that, you can predict execution order exactly, and understand why a long loop or a runaway promise chain freezes the page. Every ordering example in this post was run to confirm its output.

Why It Matters

Execution order explains real bugs:

  • a loading spinner that never appears, because the work runs before the browser can paint
  • state read “too early”, because a promise callback hasn’t run yet
  • flaky tests that depend on whether a timer or a promise fires first

It is also the basis of responsiveness: Interaction to Next Paint measures exactly how long the event loop is too busy to respond.

What the Event Loop Is

JavaScript runs on one thread: one call stack, one line at a time. Yet a page can wait for the network, respond to clicks and run timers at once. The event loop is how: it is the scheduler built into the browser (and into Node.js) that decides which piece of code runs next.

  • The work that waits isn’t done by JavaScript. The browser or Node.js handles network requests, timers and user input outside the JavaScript thread.
  • When that work finishes, its callback is queued, not run immediately.
  • The event loop picks the next callback from the queues, but only when the call stack is empty, and runs it to completion.

An analogy: a single cook (the JavaScript thread) with an order rail (the queues). Ovens and fridges (the browser) do their work on their own, but nothing reaches a plate until the cook finishes the current dish and takes the next ticket. A cook who spends ten minutes on one dish holds up every other order, which is exactly what a long task does to a page.

The Pieces

Task queuesetTimeout, setIntervalclicks, input, messagesnetwork callbacksCall stackruns ONE taskto completionMicrotask queue.then / .catch / awaitqueueMicrotaskdrained COMPLETELYRender (maybe)rAF → style → layout→ paint1234: next task
One turn of the event loop: take one task, drain all microtasks, give the browser a chance to render, repeat.
  • Call stack: where code actually runs. A task starts with an empty stack and ends when it is empty again; nothing can interrupt it.
  • Task queue (also called the macrotask queue; browsers actually have several, with priorities): timer callbacks, user events, postMessage, I/O callbacks.
  • Microtask queue: promise reactions (then, catch, finally), code after await, queueMicrotask and MutationObserver. It is drained completely after every task, including microtasks added while draining.
  • Rendering: between tasks, the browser may run requestAnimationFrame callbacks, recalculate styles and layout, and paint, typically up to once per frame (about every 16.7 ms at 60 Hz).

Predicting the Order

console.log('sync');
setTimeout(() => console.log('timeout'), 0);
Promise.resolve().then(() => console.log('promise'));
queueMicrotask(() => console.log('microtask'));
console.log('sync 2');
// sync, sync 2, promise, microtask, timeout
  1. Run the current task: both sync logs
  2. Drain microtasks in order: promise, microtask
  3. Maybe render
  4. Next task: timeout

Microtasks scheduled by microtasks still run before the next task; tasks scheduled by microtasks wait:

setTimeout(() => log('T1'), 0);
Promise.resolve().then(() => {
  log('M1');
  setTimeout(() => log('T2'), 0);
  Promise.resolve().then(() => log('M2'));
});
setTimeout(() => log('T3'), 0);
// M1, M2, T1, T3, T2

T2 was queued last, after T1 and T3 were already waiting, so it runs last.

The Node.js Event Loop

Node.js uses the same idea, run a callback to completion and drain microtasks, but organizes tasks into phases (implemented by the libuv library), visited in a fixed order on every turn:

  1. Timers: setTimeout and setInterval callbacks that are due
  2. Pending callbacks: some system I/O callbacks deferred from the last turn
  3. Poll: new I/O events (network, files); waits here when there is nothing else to do
  4. Check: setImmediate callbacks
  5. Close callbacks: such as a socket’s close event

After each callback, Node.js drains two queues before moving on: first process.nextTick callbacks, then promise microtasks. The phases explain one classic puzzle:

import { readFile } from 'node:fs';
 
readFile(import.meta.filename, () => {
  setTimeout(() => console.log('timeout'), 0);
  setImmediate(() => console.log('immediate'));
});
// immediate, timeout: always, inside an I/O callback

The file callback runs in the poll phase. The next phase is check, so setImmediate runs before the loop comes back around to timers. In the main module (outside any I/O callback), the order of the two is not guaranteed, because it depends on how quickly the loop starts.

process.nextTick

process.nextTick is a separate, higher-priority queue. In a CommonJS file it runs before promise callbacks:

// CommonJS (main.cjs)
setTimeout(() => console.log('timeout'), 0);
Promise.resolve().then(() => console.log('promise'));
process.nextTick(() => console.log('nextTick'));
console.log('sync');
// sync, nextTick, promise, timeout

In an ES module the order can differ, because the module body itself runs inside promise-based machinery, and in practice promise callbacks queued by the module run first. Don’t write code that depends on the order of nextTick vs promises; if order matters, chain the work explicitly. Node.js’s own docs recommend queueMicrotask or setImmediate over nextTick for most code.

Why the Page Freezes

Rendering happens only between tasks, after the microtask queue is empty. Two things therefore block it:

A long task. Any synchronous work that takes a long time, such as parsing a huge JSON file, a heavy loop or rendering a large list, keeps the stack busy. The browser can’t paint or respond to clicks until it finishes. Anything over 50 ms counts as a “long task” and will show up as poor INP.

Microtask starvation. A promise callback that keeps scheduling more microtasks never lets the queue empty, so the loop never reaches rendering or the next task:

function spin() {
  Promise.resolve().then(spin); // the page never paints again
}

The Spinner That Never Shows

This is the most common way the rule shows up in real code:

button.addEventListener('click', () => {
  spinner.hidden = false;
  buildReport(); // 2 seconds of synchronous work
  spinner.hidden = true;
});

The spinner never appears. The DOM changes, but the browser only paints after the click task ends, and by then hidden is true again. To show it, let the browser paint before starting the heavy work, then do the work in a later task:

button.addEventListener('click', async () => {
  spinner.hidden = false;
  await new Promise((resolve) => requestAnimationFrame(() => setTimeout(resolve, 0)));
  buildReport(); // the spinner is on screen now
  spinner.hidden = true;
});

requestAnimationFrame runs just before the next paint, and the setTimeout inside it resumes in a task after that paint. Better still, split buildReport into chunks or move it to a Web Worker (both below), so the spinner can also animate.

await doesn’t help with either. A resolved promise only moves the rest of your function into the microtask queue, which is still drained before rendering.

Yielding: Letting the Browser Breathe

To keep the page responsive, split long work into chunks and yield to the event loop between them, so input and rendering can happen:

async function processAll(items) {
  for (const [index, item] of items.entries()) {
    process(item);
    if (index % 100 === 0) await yieldToMain();
  }
}
 
function yieldToMain() {
  if (globalThis.scheduler?.yield) return scheduler.yield();
  return new Promise((resolve) => setTimeout(resolve, 0));
}

scheduler.yield() (available in Chromium-based browsers; check support before relying on it) yields like a task but resumes with higher priority. The fallback is a setTimeout, which is a real task, so the browser gets a chance to render. A resolved Promise would not work here, because it is a microtask.

You can feel the difference in the Main thread jam lab game: add a 300 ms task and taps take 300 ms or more to show up; split it into small chunks and response time drops back to a few milliseconds.

One long task120 ms of work: nothing paints, input waitsframe boundaries every 16.7 msSplit into chunkspaintpaintinputpaintpaintpaintpaint12 ms
At 60 Hz the browser wants to paint every 16.7 ms. One long task blocks every frame it overlaps; the same work in short chunks leaves room to handle input and paint between them.

Timers Are a Minimum, Not a Schedule

setTimeout(fn, 100) means “not sooner than 100 ms”. The callback runs when its task reaches the front of the queue and the call stack is free, so a long task delays every timer behind it. Browsers also slow timers down on purpose:

  • After five levels of nested timers, the minimum delay becomes 4 ms.
  • Background tabs throttle timers heavily, typically to once per second or less, to save battery.
  • setInterval doesn’t catch up: if the page is busy, ticks are delayed, not queued up to run back to back.

So never count timer callbacks to measure time. Compare timestamps (performance.now() or Date.now()) instead, and use requestAnimationFrame for anything visual.

Moving Work off the Main Thread: Web Workers

Yielding keeps the page responsive, but the work still runs on the main thread. For work that is genuinely heavy, such as parsing a large file, image processing or search indexing, a Web Worker runs it on a separate thread with its own event loop:

// main.js
const worker = new Worker(new URL('./parse-worker.js', import.meta.url), { type: 'module' });
worker.onmessage = (event) => renderTable(event.data);
worker.postMessage(csvText); // returns immediately; the page stays responsive
 
// parse-worker.js
self.onmessage = (event) => {
  self.postMessage(parseCsv(event.data)); // runs on the worker’s thread
};

Workers can’t touch the DOM, and messages are copied (with the structured clone algorithm), which costs time for very large data. Pass an ArrayBuffer in the transfer list to move it without copying. Node.js has the same idea in worker_threads.

Going Deeper: Priorities, Frames and Measuring in Production

The simple model (one task queue) is enough to predict ordering. To tune responsiveness, three more details matter.

There are several task queues, with priorities.

  • The HTML standard lets browsers keep separate queues for different task sources and choose between them. In practice, browsers favour user input over timers and network callbacks.
  • You can state your own priorities with scheduler.postTask(fn, { priority }): user-blocking, user-visible or background (Chromium-based browsers; check support).
  • React’s scheduler does something similar internally, using MessageChannel messages, which, unlike nested setTimeout, are not clamped to 4 ms.

Rendering is an opportunity, not a guarantee. After a task and its microtasks, the browser may render. It skips the step when nothing visual changed, when it is behind schedule, and in background tabs, where requestAnimationFrame callbacks stop entirely. Don’t use requestAnimationFrame for work that must finish, such as saving data; use it only for visual updates.

Measure long tasks where users are. Development machines are fast; your users’ phones are not. In the field:

  • Interaction to Next Paint (INP) measures how long it takes for the page to visually respond to clicks, taps and key presses, and is a Core Web Vital.
  • The Long Animation Frames API (PerformanceObserver with the long-animation-frame type, Chromium 123+) reports frames that took more than 50 ms, including the scripts that caused them, which is how you find which code blocks the main thread in production.
  • Libraries such as web-vitals collect these numbers and send them to your analytics.

A senior-level habit: when someone reports “the page feels slow”, ask whether it is load (network and parsing), responsiveness (long tasks blocking input, measured by INP) or smoothness (dropped frames). The event loop explains the last two, and the fixes are different from the first.

Which Scheduling Tool to Use

You want toUseRuns
Run something after the current code, before anything elsequeueMicrotask / a promiseMicrotask
Update the screen before the next paintrequestAnimationFrameBefore rendering
Let input and rendering happen firstsetTimeout(fn, 0) or scheduler.yield()Next task
Do low-priority work when idlerequestIdleCallbackWhen the browser is idle
Run heavy CPU work without blockingA Web WorkerOn another thread

Test Yourself

Predict the output, then open the answer. Every answer was checked by running the code.

Desafio 1

Microtasks From Three Sources

setTimeout(() => console.log('timeout'), 0);
queueMicrotask(() => console.log('micro 1'));
(async () => {
  console.log('async start');
  await undefined;
  console.log('async end');
})();
Promise.resolve().then(() => console.log('micro 2'));
console.log('sync');
Ver resposta

async start, sync, micro 1, async end, micro 2, timeout

  • The async function runs synchronously until its await, so async start prints with sync.
  • Three microtasks are queued, in order: micro 1, the rest of the async function, micro 2.
  • They all run before the timer task.

Desafio 2

A Timer Inside a Microtask, a Microtask Inside a Timer

setTimeout(() => {
  console.log('T1');
  Promise.resolve().then(() => console.log('M from T1'));
}, 0);
setTimeout(() => console.log('T2'), 0);
Promise.resolve().then(() => {
  console.log('M1');
  setTimeout(() => console.log('T from M1'), 0);
});
Ver resposta

M1, T1, M from T1, T2, T from M1

  • The microtask runs first: M1.
  • Each timer task runs, and the microtasks it creates are drained before the next task: that is why M from T1 comes before T2.
  • T from M1 was queued last, so it runs last.

Desafio 3

Will This Ever Print?

let done = false;
setTimeout(() => {
  done = true;
}, 0);
while (!done) {
  // wait for the timer
}
console.log('finished');
Ver resposta

Never: the page (or process) hangs.

  • The timer callback can only run after the current task finishes, and the current task is the loop waiting for it.
  • JavaScript can’t wait by spinning; it waits by returning to the event loop, for example with await.

Desafio 4

Where Does an Awaited Function Run?

async function a() {
  console.log('a1');
  await b();
  console.log('a2');
}
async function b() {
  console.log('b');
}
a();
console.log('sync');
Ver resposta

a1, b, sync, a2

  • Calling b() runs its body synchronously, so b prints immediately.
  • Only the code after the await waits: it becomes a microtask that runs once the current synchronous code (sync) is done.

Desafio 5

Does await in a Loop Let Timers Run?

setTimeout(() => console.log('timeout'));
(async () => {
  for (let i = 0; i < 3; i++) {
    await null;
    console.log('tick', i);
  }
})();
Ver resposta

tick 0, tick 1, tick 2, timeout

  • Each await null only schedules a microtask, and the microtask queue is drained completely before the next task.
  • The timer, a task, waits until the whole loop is done.
  • To let timers, input and rendering in, yield with setTimeout or scheduler.yield().

Key Takeaways

  • The loop runs one task to completion, drains all microtasks, may render, then takes the next task.
  • Timers and events are tasks; promise callbacks, await continuations and queueMicrotask are microtasks.
  • Nothing renders while a task runs or while microtasks keep being added.
  • Split long work and yield with setTimeout or scheduler.yield(); a resolved promise does not yield.
  • Don’t depend on fragile orderings such as nextTick vs promises; chain work explicitly when order matters.
  • Timers are minimum delays, not schedules; move genuinely heavy work to a Web Worker.
  • Node.js runs its tasks in phases (timers, poll, check and others), which is why setImmediate beats setTimeout inside an I/O callback.

Next in the series, how promises and async/await work under the hood builds on this model to explain promise chains and await.

Sources

Tags

Perguntas frequentes

Does setTimeout(fn, 0) run immediately?

NoIt schedules a task that runs after the current task and all microtasks finish, and browsers raise the delay to at least 4 ms after five levels of nested timers. Zero means “as soon as possible after now”, not “now”.

Does async/await make code run in parallel?

NoJavaScript still runs one piece of code at a time on the main thread. await only lets other work run while you wait for something outside JavaScript, such as a network response. CPU-heavy work still blocks; move it to a Web Worker.

Is Node.js’s event loop the same as the browser’s?

NoNot exactly. Same idea, different details. Node.js runs its loop in phases (timers, I/O callbacks, check for setImmediate, close callbacks) and has process.nextTick, which runs before promise callbacks in CommonJS code. Browsers add a rendering step.

Why does an await in a loop still freeze the page?

Awaiting an already resolved value only schedules a microtask, and microtasks run before rendering and input. The page only gets a chance to respond when your code returns to the event loop with a task, for example after setTimeout or scheduler.yield().

What is INP, and how does the event loop affect it?

Interaction to Next Paint measures the time from a user interaction to the next frame that shows its result. Long tasks before or during the event handler, and slow rendering after it, all add to INP, so splitting work and yielding improves it.

When should I use requestAnimationFrame?

For visual updates that should happen just before the next paint, such as animations, measuring layout once per frame or batching DOM writes. It doesn’t run in background tabs, so never use it for work that must complete.

How do I find long tasks in production?

Observe long-animation-frame entries with PerformanceObserver (Chromium 123+), which include the scripts responsible, and track INP with a library such as web-vitals. Real user data shows problems that fast development machines hide.

Do Web Workers share memory with the main thread?

NoNot by default: they communicate by posting messages, which copies the data (or transfers ownership of an ArrayBuffer). SharedArrayBuffer allows real shared memory, but requires a cross-origin isolated page.