Javascript
The Event Loop: One Thread, Many Queues
Reading level
Zero milliseconds — but not zero wait
You write setTimeout(fn, 0) expecting fn to run immediately.
It doesn't. Something else runs first. You add await to a fetch — and a Promise
you never awaited resolves before your next line. The console logs come out in the wrong order.
JavaScript has exactly one thread. Yet it can handle user clicks, network responses, animations, and timers all at once — without crashing or mixing them up. How? The event loop.
Once you see the event loop clearly, a whole category of "why did this run first?" bugs disappears forever.
setTimeout(fn, 0) schedules a macrotask. An immediately-resolved
Promise.then(fn) schedules a microtask. Microtasks drain completely
before the next macrotask — and before the browser paints. This ordering is specified, not
accidental, and it has real consequences for perceived responsiveness.
The bugs it produces are subtle: a DOM update that doesn't render because it's inside a microtask flood; a "stale" event listener value; a progress bar that jumps to 100% without intermediate steps.
The HTML Living Standard defines the event loop processing model precisely: run the oldest macrotask from the task queue, drain all microtasks, optionally run rendering steps, repeat. That "optionally" is controlled by the browser's rendering pipeline — typically aligned to vsync (~16.7ms at 60fps). The gap between "task runs" and "user sees the result" is where perceived latency lives.
Three queues, one thread
Think of JavaScript like a chef with one pair of hands. The chef can only do one thing at a time, but has three kinds of tickets:
- Big tickets (macrotasks): setTimeout, setInterval, I/O, user events
- Urgent tickets (microtasks): Promise callbacks, queueMicrotask
- Paint tickets (render steps): requestAnimationFrame, style calculation
The rule: finish one big ticket → clear all urgent tickets → paint if it's time → next big ticket. Urgent tickets always jump the queue ahead of the next big ticket.
The event loop spec has this shape per iteration:
- Pick the oldest task from the task queue (macrotask) — run it to completion
- Drain the microtask queue completely (each microtask can enqueue more microtasks)
- If a frame is due: run requestAnimationFrame callbacks, then update rendering
- If nothing is pending: wait; otherwise repeat
The implication: a Promise chain that resolves synchronously can starve the rendering pipeline if it keeps enqueuing more microtasks. This is the microtask flood anti-pattern.
Two additional layers matter in production:
-
Scheduler API (
scheduler.postTask): Chrome 94+ allows explicit priority hints (user-blocking,user-visible,background) to influence task ordering — giving you coarse control over the macrotask queue beyond the single-queue model. -
Web Workers: Separate event loop on a separate thread. Offload CPU-bound
work (parsing, compression, heavy computation) without blocking the main loop. Message
passing via
postMessagetriggers a macrotask on the receiving end.
Long Animation Frames (LoAF) in Chrome DevTools now surfaces when a single task + microtask drain exceeds 50ms — this is your production signal for event loop contention.
The blocking script problem
Here's a classic trap: you have a list of 10,000 items to process. You write a loop that processes them all at once. The page freezes. Scrolling stops. Users see the spinning cursor. The browser can't render anything because JavaScript is still running.
This is called blocking the main thread. JavaScript is running, but the event loop can't move on to rendering or handling clicks — because the current task hasn't finished.
// ❌ Blocks the main thread — no rendering during loop
function processAll(items) {
for (const item of items) {
heavyTransform(item); // each takes ~1ms = 10s total
}
}
Long tasks prevent the browser from responding to user input and updating the display. The Performance panel shows them as red blocks in the main thread timeline. Total Blocking Time (TBT) — the INP successor metric's cousin — measures this directly.
The subtler version: a Promise chain that never yields. Each .then() is a
microtask — they drain before rendering. A chain of 500 tight microtasks is as blocking
as a tight for-loop from the rendering pipeline's perspective.
The Long Tasks API (PerformanceObserver + longtask entry type)
surfaces tasks > 50ms in production. Combined with attribution timing, you can identify
which script caused the blockage. LoAF (Long Animation Frames) extends this to include
the full rendering update blocked by long tasks.
Yielding to the event loop
The fix is to break work into small chunks and let the event loop breathe between chunks.
The simplest way: wrap each chunk in a setTimeout(0) (or better,
scheduler.yield() if available).
// ✅ Yields between chunks — browser can paint + handle clicks
async function processInChunks(items, chunkSize = 100) {
for (let i = 0; i < items.length; i += chunkSize) {
const chunk = items.slice(i, i + chunkSize);
chunk.forEach(heavyTransform);
await new Promise(r => setTimeout(r, 0)); // yield
}
}
Each await setTimeout(0) pauses execution, returns control to the event loop
(letting it render and handle events), then comes back. The work takes the same total time,
but the page stays responsive.
Better still, use the Scheduler API when available:
// Progressive enhancement — use scheduler.yield if available
const yieldToMain = () =>
typeof scheduler !== 'undefined' && scheduler.yield
? scheduler.yield()
: new Promise(r => setTimeout(r, 0));
async function processInChunks(items) {
for (let i = 0; i < items.length; i += 100) {
items.slice(i, i + 100).forEach(heavyTransform);
await yieldToMain();
}
}
scheduler.yield() is prioritized differently from a 0ms timeout — it
cooperates with the browser's scheduling without the artificial 1ms minimum delay browsers
clamp setTimeout(0) to.
For genuinely parallel CPU work, a Web Worker is the right tool. The main thread stays
completely free; results arrive as a macrotask via postMessage:
// worker.js
self.onmessage = ({ data: items }) => {
const results = items.map(heavyTransform);
self.postMessage(results);
};
// main.js
const worker = new Worker('/worker.js');
worker.postMessage(items);
worker.onmessage = ({ data: results }) => updateUI(results);
Decision heuristic: use chunked processing when total work is <1s; use a Worker when > 1s or when work can be easily serialized.
Task vs microtask execution order
Order proof — run this in the browser console:
console.log('1 — synchronous');
setTimeout(() => console.log('2 — macrotask (setTimeout 0)'), 0);
Promise.resolve().then(() => console.log('3 — microtask (Promise)'));
queueMicrotask(() => console.log('4 — microtask (queueMicrotask)'));
console.log('5 — synchronous');
// Output order: 1, 5, 3, 4, 2
// Synchronous first → microtasks before macrotask
call stack
call stack
Promise
queue
setTimeout
Try it: blocking vs yielding
Click "Blocking" — the counter freezes while work runs. Click "Yielding" — the counter keeps ticking because the loop yields to the event loop between chunks.
Notice how yielding maintains frame budget (≤16ms per task). The counter update is a rAF callback — it only runs when the event loop reaches render steps.
Open DevTools Performance while clicking "Blocking" — you'll see a long task ≥50ms and TBT accumulation. "Yielding" shows many short tasks below the 50ms threshold.
Showing: Yielding — responsive
Measuring and fixing event loop pressure
The simplest rule: if your page ever freezes or drops frames, check for synchronous loops over large data. Use the browser's Performance tab → look for long red blocks in the main thread timeline.
To fix: add await new Promise(r => setTimeout(r, 0)) inside long loops.
Observe long tasks in production with the Long Tasks API:
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.duration > 50) {
console.warn('Long task:', entry.duration.toFixed(1) + 'ms');
}
}
});
observer.observe({ type: 'longtask', buffered: true });
In React: expensive renders block the event loop like any other synchronous work.
React.startTransition marks state updates as lower priority — React can
yield mid-render to handle urgent updates (e.g. typing).
The full toolchain for event loop observability:
- Long Tasks API: entry.duration > 50ms threshold
- Long Animation Frames (LoAF): Chrome 123+ — whole frame duration including microtask drain + render work
- INP (Interaction to Next Paint): measures the worst input delay including event handling + style/paint — directly tied to event loop health
- scheduler.postTask: explicit task priority hints (Chrome 94+)
The mental model for production: every macrotask > 50ms is a potential INP regression. Every microtask flood is a potential invisible rendering pause. Measure first, optimize where it shows in RUM.
References
Remember
Key takeaways
-
Microtasks (Promises) always run before the next macrotask (setTimeout) — that's why order surprises you.Microtask queue drains completely before rendering — a flood of .then() callbacks is as blocking as a for-loop.Use LoAF and INP attribution in production RUM to pinpoint which task sources cause rendering gaps.
-
Break big loops into chunks with setTimeout(0) — the page stays responsive while you process.Use scheduler.yield() over setTimeout(0) where available — it cooperates better with browser scheduling priorities.CPU-bound work >1s belongs in a Web Worker — message passing adds one macrotask round-trip but keeps the main loop completely free.
-
requestAnimationFrame runs just before the browser paints — use it for smooth visual updates, not data processing.React.startTransition marks state updates as interruptible — React can yield mid-render when urgent work appears.Any task >50ms is a Long Task and a potential INP regression — the Long Tasks API and LoAF give you production-grade measurement hooks.
Keep going
Finish this takeaway, then continue the track — Casey saved your spot locally.
Sign in with email to sync progress across devices (beta).
Inside the Casebook
New cases every few weeks — patterns from production UI engineering. Double opt-in, easy unsubscribe.
No spam. Unsubscribe anytime. Emails sent via Buttondown.
RSS feed