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 мин чтения
Эта страница пока недоступна на русском языке, поэтому вы читаете английский оригинал.
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
- 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 afterawait,queueMicrotaskandMutationObserver. It is drained completely after every task, including microtasks added while draining. - Rendering: between tasks, the browser may run
requestAnimationFramecallbacks, 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- Run the current task: both
synclogs - Drain microtasks in order:
promise,microtask - Maybe render
- 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, T2T2 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:
- Timers:
setTimeoutandsetIntervalcallbacks that are due - Pending callbacks: some system I/O callbacks deferred from the last turn
- Poll: new I/O events (network, files); waits here when there is nothing else to do
- Check:
setImmediatecallbacks - Close callbacks: such as a socket’s
closeevent
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 callbackThe 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, timeoutIn 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.
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.
setIntervaldoesn’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-visibleorbackground(Chromium-based browsers; check support). - React’s scheduler does something similar internally, using
MessageChannelmessages, which, unlike nestedsetTimeout, 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 (
PerformanceObserverwith thelong-animation-frametype, 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-vitalscollect 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 to | Use | Runs |
|---|---|---|
| Run something after the current code, before anything else | queueMicrotask / a promise | Microtask |
| Update the screen before the next paint | requestAnimationFrame | Before rendering |
| Let input and rendering happen first | setTimeout(fn, 0) or scheduler.yield() | Next task |
| Do low-priority work when idle | requestIdleCallback | When the browser is idle |
| Run heavy CPU work without blocking | A Web Worker | On another thread |
Test Yourself
Predict the output, then open the answer. Every answer was checked by running the code.
Задача 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');Показать ответСкрыть ответ
async start, sync, micro 1, async end, micro 2, timeout
- The async function runs synchronously until its
await, soasync startprints withsync. - Three microtasks are queued, in order:
micro 1, the rest of the async function,micro 2. - They all run before the timer task.
Задача 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);
});Показать ответСкрыть ответ
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 T1comes beforeT2. T from M1was queued last, so it runs last.
Задача 3
Will This Ever Print?
let done = false;
setTimeout(() => {
done = true;
}, 0);
while (!done) {
// wait for the timer
}
console.log('finished');Показать ответСкрыть ответ
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.
Задача 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');Показать ответСкрыть ответ
a1, b, sync, a2
- Calling
b()runs its body synchronously, sobprints immediately. - Only the code after the
awaitwaits: it becomes a microtask that runs once the current synchronous code (sync) is done.
Задача 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);
}
})();Показать ответСкрыть ответ
tick 0, tick 1, tick 2, timeout
- Each
await nullonly 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
setTimeoutorscheduler.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,
awaitcontinuations andqueueMicrotaskare microtasks. - Nothing renders while a task runs or while microtasks keep being added.
- Split long work and yield with
setTimeoutorscheduler.yield(); a resolved promise does not yield. - Don’t depend on fragile orderings such as
nextTickvs 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
setImmediatebeatssetTimeoutinside 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
- MDN: Scheduler.postTask()
- Chrome for Developers: Long Animation Frames API
- web.dev: Interaction to Next Paint (INP)
- HTML Standard: Event loop processing model
- Jake Archibald: Tasks, microtasks, queues and schedules
- MDN: Using microtasks in JavaScript with queueMicrotask()
- Node.js docs: The Node.js event loop
- web.dev: Optimize long tasks
- MDN: Scheduler.yield()
- MDN: setTimeout(): reasons for delays longer than specified
- MDN: Using Web Workers
Теги
Частые вопросы
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.