The JavaScript Event Loop, Microtasks vs Macrotasks (with Timing Examples)
JavaScript is single-threaded but routinely handles thousands of network requests, timers, and DOM events without blocking. The trick behind this is the event loop — the runtime mechanism that coordinates async work around the single call stack.
If you have ever wondered why Promise.resolve().then(...) always runs before setTimeout(..., 0), or why a tight loop freezes your UI, the answer is the event loop. This article unpacks it.
This is Part 7 of the JavaScript Fundamentals series. You will get more out of it after reading Part 1 on execution contexts and the call stack — the event loop is the mechanism that feeds that call stack.
The shape of the runtime #
Three pieces work together at runtime:
┌────────────────────────────────┐
│ Call Stack │ ← what's running RIGHT NOW (one thing)
└────────────────────────────────┘
▲
│ event loop pulls
│
┌────────────┴───────────────────┐
│ Microtask Queue │ ← Promises, queueMicrotask, MutationObserver
├────────────────────────────────┤
│ Macrotask Queue (Task Queue) │ ← setTimeout, setInterval, I/O, UI events
└────────────────────────────────┘
- Call stack — synchronous code runs here. Always finishes before anything async kicks in.
- Microtask queue — high-priority work scheduled by
Promise.then,await,queueMicrotask, andMutationObserver. - Macrotask queue — lower-priority work scheduled by
setTimeout,setInterval, network I/O (in Node), DOM events, andMessageChannel.
The event loop is a tiny algorithm:
- If the call stack is empty, drain the entire microtask queue.
- Run one macrotask.
- After that macrotask completes, drain the microtask queue again.
- (Browsers) Render if needed.
- Repeat.
That is it. The whole asynchronous JavaScript world is that loop.
Why microtasks always win #
The key word is drain. After every macrotask, the loop drains every microtask before running another macrotask. That is why:
setTimeout(() => console.log('macro'), 0);
Promise.resolve().then(() => console.log('micro'));
console.log('sync');
// Logs:
// sync
// micro
// macro
Walk it:
setTimeoutschedules a macrotask (queued for>= 0ms).Promise.resolve().then(...)schedules a microtask.console.log('sync')runs — synchronous code on the stack.- The stack empties. Event loop checks microtasks → finds the promise callback. Logs
'micro'. - Microtask queue empty. Loop pulls the next macrotask → the timeout callback. Logs
'macro'.
Even with setTimeout(..., 0) — "as soon as possible" — microtasks still run first. The 0ms is a minimum, and the timer's callback is a macrotask either way.
A trickier example #
console.log('1');
setTimeout(() => console.log('2'), 0);
Promise.resolve()
.then(() => console.log('3'))
.then(() => console.log('4'));
console.log('5');
Order?
console.log('1')— sync.setTimeoutschedules macrotask.- First
.thenschedules microtask. console.log('5')— sync. (The second.thendoes NOT schedule a microtask yet — it's chained to a promise that hasn't resolved its callback.)- Stack empty. Drain microtasks.
- Run first promise callback → logs
'3'. The second.thennow fires because its parent has resolved → adds another microtask. - Microtask queue is checked again before exit — runs the new microtask → logs
'4'. - Microtask queue empty. Pull macrotask. → logs
'2'.
Final: 1 5 3 4 2.
The non-obvious step is 7: microtasks scheduled by other microtasks are processed in the same drain. This is why an infinite microtask chain (e.g., function tick(){ Promise.resolve().then(tick) }) freezes the browser — the event loop never gets to run a macrotask or repaint.
await is just .then in disguise #
When you await something, the JavaScript engine suspends the function and schedules the continuation as a microtask:
async function run() {
console.log('a');
await Promise.resolve();
console.log('b');
}
run();
console.log('c');
// Logs:
// a
// c
// b
The code before await runs synchronously. The code after is a microtask scheduled when the awaited value resolves. That is why b comes after c (which is sync) but before any setTimeout callbacks.
For more on async/await internals, see our two posts: Unlocking Async/Await Beyond Syntactic Sugar and Deciphering the Elegant Syntactic Sugar of Async/Await.
What counts as a macrotask? #
In the browser:
setTimeout,setIntervalcallbacks- DOM events (
click,keydown,load) XMLHttpRequest/fetchevent callbacksMessageChannel.postMessagerecipientsrequestIdleCallback
In Node.js, the picture is more nuanced — there are multiple macrotask phases (timers, pending callbacks, idle/prepare, poll, check, close) plus setImmediate (which is a separate macrotask category). But the microtask drain rule still applies between phases.
What counts as a microtask? #
- Promise
then/catch/finallycallbacks awaitcontinuationsqueueMicrotask(fn)— explicit API for scheduling oneMutationObservercallbacks (in the browser)- Node.js
process.nextTick— actually a separate, higher-priority queue, but lumped with microtasks for most mental models
process.nextTick (Node-only) is even more aggressive #
In Node:
process.nextTick(() => console.log('nextTick'));
Promise.resolve().then(() => console.log('promise'));
// Logs: nextTick, promise
process.nextTick callbacks drain before any promise microtasks. Use it sparingly — abusing it can starve the rest of the event loop.
Rendering and the event loop (browser) #
In the browser, the event loop also coordinates rendering:
┌─────────────────────────────────┐
│ 1. Run one macrotask │
├─────────────────────────────────┤
│ 2. Drain microtask queue │
├─────────────────────────────────┤
│ 3. Run requestAnimationFrame │
├─────────────────────────────────┤
│ 4. Layout + Paint (if needed) │
└─────────────────────────────────┘
Key implications:
- Long synchronous work blocks rendering. A 200ms blocking loop means no frame drawn for 200ms.
- Microtasks block rendering too. If you keep scheduling microtasks, you delay step 3 forever.
requestAnimationFramefires right before paint — perfect for animations.- DOM measurements should happen in
requestAnimationFrame— that's when layout is fresh.
This is why a for (let i = 0; i < 1e9; i++) {} loop freezes the UI — and why offloading to a Web Worker (or breaking work across setTimeout(..., 0) chunks) keeps the page interactive. See our post on Web Workers and Service Workers for the parallelism story.
Practical pattern: breaking up long work #
If you have a long CPU-bound task (large array processing, JSON serializing huge objects), break it into chunks separated by setTimeout(..., 0) so the event loop can render and process events between chunks:
function processChunked(items, chunkSize = 100) {
let i = 0;
function next() {
const end = Math.min(i + chunkSize, items.length);
for (; i < end; i++) heavyWork(items[i]);
if (i < items.length) setTimeout(next, 0);
}
next();
}
Better alternative in modern browsers: scheduler.postTask() (with priority levels), or a Web Worker for genuinely heavy work.
Pattern: yield to the event loop with await #
A neat trick — yield to the event loop in async code by awaiting a resolved promise:
async function process(items) {
for (let i = 0; i < items.length; i++) {
heavyWork(items[i]);
if (i % 100 === 0) await new Promise(r => setTimeout(r, 0));
// ↑ lets the event loop run one full cycle (macrotask + microtasks + render)
}
}
Note await Promise.resolve() alone is NOT enough — that just schedules a microtask and the drain runs immediately without yielding to rendering. You need a real macrotask (setTimeout 0, MessageChannel, or scheduler.yield()).
Common gotchas #
Gotcha 1: Microtask starvation.
function tick() { Promise.resolve().then(tick); }
tick(); // freezes the page
The microtask drain runs until empty. A self-scheduling promise chain never empties.
Gotcha 2: setTimeout(fn, 0) is NOT zero.
Browsers clamp nested timeouts to 4ms after 5 levels. The minimum first-level delay is ~1ms. For "as soon as possible", use queueMicrotask(fn) (microtask) or MessageChannel (macrotask without timer clamping).
Gotcha 3: Order across promises feels random until you internalize the microtask drain.
If two promises resolve at the same time, their then callbacks run in the order they were registered. Within one microtask drain, callbacks run in FIFO order.
Gotcha 4: Awaiting in a loop is sequential.
for (const url of urls) {
await fetch(url); // serial!
}
For parallel, gather promises first:
await Promise.all(urls.map(u => fetch(u)));
Our post on sequential API calls covers when you actually want each pattern.
Recap #
- The event loop = one call stack + microtask queue + macrotask queue, coordinated by a tiny algorithm.
- Rule: the microtask queue is fully drained between every macrotask.
- That is why
Promise.thenalways runs beforesetTimeout(0). awaitcontinuations are microtasks. Code beforeawaitis sync.- Browsers also run rendering steps in the loop — long sync work or runaway microtasks freeze the UI.
- For long CPU-bound work, yield via
setTimeout 0,scheduler.yield(), or a Web Worker.
Next up: Promises Under the Hood — we build a Promise from scratch in 60 lines and watch how then, microtask scheduling, and chaining actually work.
Up next in JavaScript
More from this topic
Enjoyed this article?
Get new JavaScript tutorials delivered. No spam — just code-first articles when they ship.


