How the Browser Event Loop Works: Tasks, Microtasks, Rendering
Advanced4 minInnovialabs editorial team
In short
The browser takes one task, runs it to completion, drains the whole microtask queue and only then may paint a frame; while your code runs, the page neither repaints nor responds to clicks.
The short answer
A page has one main thread. It runs JavaScript, handles events and paints. The event loop is the cycle that decides what happens next:
- Take one task from a queue and run it to completion.
- Run all microtasks, including any queued along the way.
- If a frame is due, run requestAnimationFrame callbacks, recalculate style and layout, and paint.
- Repeat.
The main rule follows: while your code runs, the browser neither paints nor handles clicks.
The call stack
When a function is called, it is pushed onto the call stack; when it returns, it is popped. The event loop picks the next task only when the stack is empty. A long synchronous function keeps the stack busy, and everything else waits.
Tasks and microtasks
| Tasks | Microtasks | |
|---|---|---|
| Sources | setTimeout, setInterval, user events, network callbacks, MessageChannel | Promise.then/catch/finally, await, queueMicrotask, MutationObserver |
| How many per turn | One | The whole queue |
| Rendering in between | Possible | Not possible |
Puzzle 1: output order
console.log("1");
setTimeout(() => console.log("2"), 0);
Promise.resolve().then(() => console.log("3"));
console.log("4");
Result: 1, 4, 3, 2.
1and4are synchronous code in the current task.3is a microtask: it runs right after the current task finishes.2is a new task: it waits for the next loop turn.setTimeout(fn, 0)means "no sooner than", not "immediately".
Puzzle 2: why the spinner never spins
button.addEventListener("click", () => {
spinner.hidden = false;
heavyCalculation(); // several seconds of synchronous work
spinner.hidden = true;
});
The spinner never appears. The DOM changes, but painting happens only after the handler finishes, and by then the spinner is hidden again. The page is frozen the whole time.
Puzzle 3: microtasks can freeze too
function loop() {
Promise.resolve().then(loop);
}
loop();
Each microtask queues another, and the microtask queue is drained to the end. The browser never reaches rendering, so the tab hangs just like with while (true). With setTimeout(loop, 0) the tab stays alive: between tasks there is a chance to paint.
Where requestAnimationFrame fits
requestAnimationFrame(callback) runs the function right before the next frame is painted. That is why animations and frame-aligned measurements belong there, not in setTimeout:
- rAF callbacks are synchronised with the display refresh rate;
- in a background tab they are usually paused and cost nothing;
- style changes from several rAF callbacks land in the same frame.
How not to freeze the UI
- Split long work into chunks and yield between them, using
setTimeoutorscheduler.yield()where supported. That gives the browser time to handle clicks and paint. - Move heavy computation to a Web Worker. A worker runs on a separate thread and talks to the main thread via messages.
- Do not interleave layout reads and writes in a loop: reading
offsetHeightafter a style change forces a synchronous layout. - Update the UI before heavy work if you need to show an indicator: show it, wait for a frame, then start computing.
async function run() {
spinner.hidden = false;
await new Promise((r) => requestAnimationFrame(() => setTimeout(r, 0)));
heavyCalculation();
spinner.hidden = true;
}
Waiting for rAF and then a setTimeout ensures the frame with the spinner gets painted. The UI still freezes during the calculation, though: only a Web Worker or chunking truly solves that.
How to see it
The Performance panel in DevTools shows tasks on the main thread. Tasks longer than about 50 ms are flagged as long tasks: they are what makes the interface unresponsive and hurts INP.