Event Loop Demystified: Tasks, Microtasks, and Rendering Frames
#webdev
#javascript
#event-loop
Introduction
The browser runtime is built on a single JavaScript thread, but it handles a lot of asynchronous work behind the scenes. The event loop is the mechanism that coordinates three ingredients you’ll hear about a lot: tasks (macrotasks), microtasks, and rendering frames. Understanding how they interact helps you write snappier code and avoid subtle timing bugs.
What is the Event Loop?
At a high level, the event loop keeps the runtime responsive by taking work from queues and giving the single thread a chance to run. It performs three broad steps repeatedly:
- Execute a macrotask from the macrotask queue
- Process all microtasks in the microtask queue
- If needed, render a new frame (layout and painting) and then continue
Key idea: microtasks have higher priority than rendering between frames. They run until empty, right after the current task finishes, before the browser paints the next frame.
Tasks vs Microtasks
- Macrotasks (tasks): The larger chunks of work scheduled by APIs like setTimeout, setInterval, I/O callbacks, UI events, and certain browser operations. Each macrotask gets a chance to run to completion.
- Microtasks: Short, critical follow-ups scheduled via Promise.then, queueMicrotask, MutationObserver, and some other lower-level APIs. Microtasks run immediately after the current macrotask finishes, and they can schedule more microtasks, forming a chain.
Execution order (typical browser behavior):
- Run a macrotask from the macrotask queue
- Flush the microtask queue (run all microtasks)
- If a render is needed, paint the frame
- Repeat with the next macrotask
Why this matters:
- If you schedule many microtasks, you can delay rendering until the microtask queue clears.
- If you do heavy work in a macrotask, the UI thread can appear unresponsive until that task completes.
Code example: microtasks chaining
console.log('start');
Promise.resolve().then(() => {
console.log('microtask 1');
return Promise.resolve().then(() => console.log('microtask 2'));
});
console.log('end');
Expected order: start, end, microtask 1, microtask 2
Code example: macrotask vs microtask with setTimeout
console.log('A');
setTimeout(() => console.log('macrotask'), 0);
Promise.resolve().then(() => console.log('microtask'));
console.log('B');
Expected order: A, B, microtask, macrotask
Queueing microtasks from within microtasks
console.log('begin');
Promise.resolve().then(() => {
console.log('microtask 1');
return Promise.resolve().then(() => console.log('microtask 2'));
});
console.log('end');
Expected order: begin, end, microtask 1, microtask 2
Rendering Frames and the Rendering Pipeline
A rendering frame represents the browser updating what you see on screen, typically aimed at 60 frames per second (about 16.7 ms per frame). The rendering pipeline involves:
- Calculating layout (flow of boxes, positions, sizes)
- Painting pixels for each element
- Compositing layers for final output
Important relationships:
- Microtasks run before the next render frame. If a microtask schedules additional microtasks, those run before painting.
- requestAnimationFrame (rAF) is aligned with the rendering frame budget. Its callbacks are invoked just before the next repaint, giving you a hook to sync visual changes to frames.
- Long-running work in a macrotask can cause dropped frames because the browser can’t complete layout/paint before the next frame.
Code example: using requestAnimationFrame
console.log('script start');
requestAnimationFrame(() => console.log('frame render'));
setTimeout(() => console.log('macrotask after frame'), 0);
console.log('script end');
Expected order (typical): script start, script end, frame render, macrotask after frame
Tips for smooth rendering:
- Use requestAnimationFrame for visual updates and animations.
- Break up long tasks into smaller macrotasks (e.g., via setTimeout or incremental work) to keep frames responsive.
- Avoid expensive operations inside microtasks; they should be lightweight follow-ups rather than heavy computation.
A Timeline: How a Typical Page Update Flows
Consider a small interaction that updates UI and fetches data:
- A click enqueues a macrotask (e.g., fetch call) and some DOM updates.
- The macrotask runs: starts the fetch, updates local state.
- The microtask queue processes queued promises (e.g., then handlers that apply results to state).
- Rendering occurs if changes affect layout or visuals, followed by another frame paint.
- If more microtasks were scheduled during the macrotask or microtask, they finish before the next render.
Concrete example:
button.addEventListener('click', () => {
// macrotask: starting work for the click
fetch('/api/data') // asynchronous operation
.then(res => res.json())
.then(data => {
// microtask: update state after data arrives
console.log('data ready', data);
});
// microtask scheduled immediately
Promise.resolve().then(() => console.log('microtask after click'));
});
Expected flow:
- Click handler runs (macrotask)
- Microtask prints “microtask after click”
- Fetch completes later; its then handlers run as microtasks
- Rendering updates occur between frames as needed
Common Pitfalls and Patterns
- Avoid long microtask chains. If a microtask chain blocks for a long time, you delay rendering and hurt interactivity.
- Use macrotasks to chunk heavy work. Break large computations into smaller units and schedule them via setTimeout or setInterval.
- For animation, rely on requestAnimationFrame. Don’t perform frame-dependent work inside microtasks.
- If you need to run code after the current call stack but before the next render, microtask is the right tool. For code that should run after rendering but before user interaction, consider a macrotask.
Conclusion
Understanding the subtle dance between macrotasks, microtasks, and rendering frames helps you build snappier UI and write more predictable asynchronous code. By prioritizing microtasks for small, immediate follow-ups and using macrotasks and requestAnimationFrame for heavier work and animation, you can keep your app responsive and the visuals smooth.