The JavaScript Event Loop: Microtasks, Macrotasks and Why Order Matters
How the event loop actually schedules work, why promises always run before setTimeout, and how to stop long tasks from freezing your page.
Table of contents
- The model
- Predicting the output
- Microtasks can starve the loop
- Long tasks are the real performance problem
- Node.js differs in one important way
- Frequently asked questions
- Why does setTimeout(fn, 0) not run immediately?
- Is async/await a different mechanism?
- Does the event loop mean JavaScript is slow?
- How do I find long tasks in my app?
- Related reading
- References
JavaScript runs your code on a single thread. The event loop is the mechanism that decides what that thread does next. Understanding it explains almost every "why did this run in that order?" question.
The model#
There is one call stack and two queues that matter:
- The macrotask queue —
setTimeout,setInterval, I/O callbacks, DOM events. - The microtask queue — promise callbacks,
queueMicrotask,MutationObserver.
The loop does this, forever:
- Run one macrotask to completion.
- Then drain the entire microtask queue.
- Then let the browser render, if needed.
- Repeat.
That "drain the entire microtask queue" step is the key to the ordering rules.
Predicting the output#
console.log('1 sync');
setTimeout(() => console.log('2 timeout'), 0);
Promise.resolve().then(() => console.log('3 promise'));
queueMicrotask(() => console.log('4 microtask'));
console.log('5 sync');Output: 1 sync, 5 sync, 3 promise, 4 microtask, 2 timeout.
Synchronous code first (it is the current macrotask). Then the microtask queue drains in order. Only then does the timeout run — even though its delay was 0.
Microtasks can starve the loop#
Because the loop drains microtasks completely before moving on, a microtask that schedules another microtask can block rendering forever:
function spin() {
Promise.resolve().then(spin); // page freezes, permanently
}A recursive setTimeout does not have this problem, because each is a separate macrotask and the browser gets a chance to render between them.
Long tasks are the real performance problem#
Anything that occupies the thread for more than ~50 ms blocks input handling — this is exactly what Lighthouse measures as Total Blocking Time and what INP penalises.
// Blocks for seconds on a large array
const results = hugeArray.map(expensiveTransform);Three ways out, in order of preference:
Yield to the scheduler. scheduler.yield() is the purpose-built API:
async function processInChunks(items, chunkSize = 500) {
const output = [];
for (let i = 0; i < items.length; i += chunkSize) {
output.push(...items.slice(i, i + chunkSize).map(expensiveTransform));
// Hand control back so input and rendering can happen.
await scheduler.yield();
}
return output;
}Move it off-thread. A Web Worker runs on its own thread, so the main thread never stalls. This is the right answer for genuinely heavy CPU work — parsing a large file, image processing, compression.
Do less. Often the fastest fix: paginate, virtualise the list, or compute on the server.
Node.js differs in one important way#
Node has the same microtask/macrotask split, plus process.nextTick, which runs before promise microtasks and has its own queue. It also splits macrotasks into phases (timers, poll, check), which is why setImmediate and setTimeout(fn, 0) are not interchangeable there.
Frequently asked questions#
Why does setTimeout(fn, 0) not run immediately?#
Because it queues a macrotask, and the current macrotask plus all pending microtasks must finish first. The browser also clamps nested timeouts to a minimum of 4 ms after five levels of nesting.
Is async/await a different mechanism?#
No. await suspends the function and resumes it as a microtask when the promise settles. It uses exactly the queues described above.
Does the event loop mean JavaScript is slow?#
No — it means JavaScript is single-threaded for your code. I/O runs in parallel in the platform's thread pool. The limitation is CPU-bound work, which is what Workers solve.
How do I find long tasks in my app?#
The Performance panel in devtools flags them with a red triangle. In production, PerformanceObserver with entryTypes: ['longtask'] reports them from real users.
Related reading#
- Async/Await Explained — parallelising work correctly
- Core Web Vitals: A Practical Guide — how blocking time becomes a ranking factor