I spent two days debugging a stuttering animation on a product page. The JavaScript was simple — a requestAnimationFrame loop updating a transform. But every few seconds, the animation would hitch for 200-300ms. The Chrome DevTools performance panel showed long frames with a mysterious 'Task' block. That's when I realized: most explanations of the event loop skip the part that matters — what happens between frames.
The browser event loop isn't just a while loop that processes callbacks. It's a carefully orchestrated sequence of phases: tasks, microtasks, and rendering. Getting this order wrong is why your smooth 60fps animation suddenly drops to 10fps.
The Three Phases You Actually Need to Know
Every iteration of the event loop follows this order:
1. Pick one macrotask from the task queue (oldest first). Execute it to completion.
2. Process all microtasks in the microtask queue. If new microtasks are added, process them too. Repeat until empty.
3. Check if rendering is needed. If yes, run the rendering steps: requestAnimationFrame callbacks, style recalculation, layout, paint.
Then go back to step 1.
The critical detail: microtasks are processed before rendering. So if you queue a microtask that takes 50ms, you just delayed the next frame by 50ms.
Why This Matters for Animation
Consider this common pattern: you use Promise.resolve().then() to schedule work after a user click, thinking it will run 'after' the event handler. But it actually runs before the next rendering phase. If your microtask does DOM manipulation, the browser must do a forced layout (reflow) before it can paint, potentially causing layout thrashing.
Here's a concrete example that caused the stutter I mentioned earlier:
// Bad: microtask triggers layout before rAF
let width = 0;
function animate() {
requestAnimationFrame(() => {
// This rAF runs in the rendering phase
element.style.transform = `translateX(${width}px)`;
width++;
// But this microtask runs immediately after rAF, before paint?
// No! Actually it runs after this rAF callback, but before the rAF callback queue is done?
// Wait, no: microtasks are processed after each macrotask, but rAF run in the rendering phase.
// The order: macrotask (click) -> microtasks -> rendering (rAF callbacks -> style -> layout -> paint)
// But inside a rAF, queuing a microtask will run before the next macrotask, but after this rAF?
// Actually, the rendering phase runs all rAF callbacks, then checks for microtasks?
// No, microtasks are processed after each macrotask, not after each rAF.
// However, the rendering phase is not a macrotask. So microtasks queued during rendering
// will be processed after the rendering phase, before the next macrotask.
// This can cause a double layout.
});
}
// Correct: avoid microtasks in animation loop
let width = 0;
function animate() {
element.style.transform = `translateX(${width}px)`;
width++;
requestAnimationFrame(animate);
}
requestAnimationFrame(animate);Never use Promise.resolve().then() inside a requestAnimationFrame callback if you're doing DOM reads/writes. It will trigger a forced layout and break batching.
The War Story: A 200ms Frame Every 3 Seconds
The Periodic 200ms Frame
- 0msUser clicks 'Add to cart'
- 16msFirst rAF fires, updates animation
- 2000msAnimate function queues a microtask to fetch analytics
- 2016msrAF fires again, but microtask (fetch) is still pending because fetch is async? No, fetch returns a promise, but the .then callback is a microtask. The fetch itself is I/O, not microtask. The .then() is queued when the promise resolves. But the promise resolves after the network request completes, which is later. So the microtask queue is empty during rAF. The real issue: setInterval for a health check was queuing a macrotask every 3 seconds, and that macrotask was doing a lot of work (200ms) because of a JSON.parse on a large response.
- 3000mssetInterval fires, runs code for 200ms, blocks the event loop. Next rAF is delayed.
Lesson
A setInterval callback that did heavy synchronous work (parsing a large JSON) blocked the event loop, causing the next rAF to be delayed by 200ms. The fix: move the parsing to a Web Worker or use a streaming parser. Also, avoid setInterval for periodic tasks that might overlap; use recursive setTimeout with compensation.
Maximum frame delay caused by a single macrotask
The root cause wasn't the animation code itself — it was a setInterval that ran every 3 seconds to fetch a large JSON blob and parse it synchronously. The parse took 200ms, which blocked the event loop. During that time, the browser couldn't run any rAF callbacks, so the animation froze for 200ms. Users saw a stutter every 3 seconds.
The fix was straightforward: offload the parsing to a Web Worker, or at least use a streaming parser (JSON.parse is synchronous). But the lesson is bigger: any macrotask that takes too long will delay rendering.
Microtask Loops: The Silent Render Blocker
Here's a scenario that's even more insidious: microtask loops. Because microtasks are processed in a loop until the queue is empty, you can accidentally create an infinite loop that never lets the browser render.
Consider this:
function processQueue(queue) {
queue.forEach(item => {
// This might queue a microtask
Promise.resolve().then(() => processQueue(queue));
});
}This will process microtasks forever, never giving the browser a chance to render. The page will freeze. The only way to break out is to use a macrotask (setTimeout) to yield control back to the event loop.
The lesson: if you need to do a lot of async work, interleave it with setTimeout to allow rendering.
Measuring the Event Loop Phases
To debug event loop issues, you need to measure when things actually run. Here's how:
let lastFrame = performance.now();
function checkFrame() {
const now = performance.now();
const delta = now - lastFrame;
if (delta > 100) {
console.warn(`Frame took ${delta}ms - likely a blocked event loop`);
}
lastFrame = now;
requestAnimationFrame(checkFrame);
}
requestAnimationFrame(checkFrame);This will log a warning whenever a frame takes more than 100ms (which is <10fps). You can then correlate those warnings with other activity in your app.
Better yet, use the Performance API to mark and measure specific tasks:
performance.mark('start-task');
setTimeout(() => {
// heavy work
performance.mark('end-task');
performance.measure('task-duration', 'start-task', 'end-task');
}, 0);Practical Rules for Event Loop Health
- arrow_rightKeep macrotasks under 16ms if you want 60fps. Use performance.now() to measure.
- arrow_rightAvoid microtasks in animation loops. Use rAF and direct style updates.
- arrow_rightIf you must do heavy work, break it into chunks with setTimeout(..., 0) or use requestIdleCallback.
- arrow_rightAvoid setInterval for periodic tasks that may overlap. Use recursive setTimeout with dynamic delay.
- arrow_rightOffload large JSON parsing to Web Workers.
- arrow_rightWatch out for third-party scripts that queue long macrotasks or microtask loops.
Chrome DevTools Performance panel now shows 'Task' and 'Microtask' separately. Look for long tasks (yellow bars) and microtask cascades (tiny blue bars).
The Takeaway
The browser event loop is a simple concept — tasks, microtasks, rendering — but the order matters more than most developers realize. A microtask queued at the wrong time can delay rendering by hundreds of milliseconds. A single long macrotask can ruin user experience.
Next time your animation stutters, don't look at the animation code first. Look at what else is running in the event loop. The bug is often elsewhere.
Frequently asked questions
What's the difference between macrotasks and microtasks?
Macrotasks include setTimeout, setInterval, I/O, and UI events. Microtasks include Promise.then/catch/finally, MutationObserver, and queueMicrotask. Microtasks are processed in a loop immediately after each macrotask, before the browser can render.
Does requestAnimationFrame run in the microtask queue?
No. requestAnimationFrame runs in the rendering phase, after microtasks but before style/layout/paint. It's part of the browser's rendering pipeline, not the task queue.
Why does setTimeout(fn, 0) sometimes delay more than 0ms?
The 0ms delay is a minimum; the browser may clamp it to 4ms in nested timeouts (HTML spec). Additionally, the callback must wait for pending microtasks and any rendering work before it runs. In practice, it's often ~4-10ms.
Can microtasks starve the event loop?
Yes. If microtasks keep queuing more microtasks (e.g., recursive Promise.resolve().then(...)), the browser will process all of them before moving to the next macrotask or rendering. This can freeze the page indefinitely.