JavaScript Event Loop (Part 2): Batching, queueMicrotask vs setTimeout
When you update state or the DOM, when that work runs depends on the event loop and the microtask queue. Batching updates correctly avoids unnecessary layout thrash and keeps UIs responsive. This post covers how the loop works and when to use queueMicrotask vs setTimeout vs requestAnimationFrame.
How the event loop works (briefly)
The JS engine runs one "turn" at a time: it takes a task from the task queue (e.g. an event callback), runs it to completion, then runs all microtasks (e.g. promise callbacks, queueMicrotask), then may do a render, then picks the next task. Microtasks run before the next task and before the next paint.
So: tasks (setTimeout, I/O, UI events) → microtasks (Promises, queueMicrotask) → render → repeat.
Why batching matters
If you do ten state updates in one synchronous block, you usually want the DOM to update once after all ten, not after each. Frameworks like Vue batch updates within the same "tick" by scheduling a single flush using the microtask queue. If you don't batch and instead trigger ten separate DOM updates, you get more layout/paint work and can see jank.
queueMicrotask: run after current code, before next task
queueMicrotask(callback) schedules callback to run after the current synchronous code finishes but before the next task (e.g. before the next setTimeout or event). All microtasks queued in the same turn run in order before the browser may render.
Use it when:
- You want to defer work to "after this function finishes" but before the next event or timer.
- You're implementing batching: collect updates synchronously, then in a microtask flush them once.
- You need something like "Promise.then" timing without creating a Promise.
Example (simple batching):
let pending = false
const updates: Array<() => void> = []
function scheduleUpdate(fn: () => void) {
updates.push(fn)
if (pending) return
pending = true
queueMicrotask(() => {
while (updates.length) updates.shift()!()
pending = false
})
}
setTimeout(fn, 0): run in the next task
setTimeout(fn, 0) queues fn as a task, so it runs after all current microtasks and after the next possible render. That means it runs later than queueMicrotask.
Use it when:
- You need to "yield" to the browser so it can paint (e.g. show a loading state before heavy work).
- You want to break a long-running synchronous block so the UI doesn't freeze (one chunk per task).
- You explicitly want to run after the next render.
requestAnimationFrame: run before the next paint
requestAnimationFrame(callback) runs callback before the next repaint, in the same general timeframe as the browser's render. It's for visual updates (DOM, canvas). It's not a replacement for microtasks or setTimeout—it's for things that should be tied to the frame.
Use it when:
- You're animating or updating something visual and want to stay in sync with the display.
- You're doing layout reads and want to batch them before writes (e.g. in a read–write–paint pattern).
Comparison
| Tool | When it runs | Typical use |
|---|---|---|
| queueMicrotask | After current code, before next task and render | Batching, "after this" logic |
| setTimeout(fn, 0) | Next task (after microtasks and possible render) | Yield to browser, chunking |
| requestAnimationFrame | Before next paint | Visual updates, animation |
Summary
- The event loop runs tasks, then microtasks, then may render; microtasks run before the next task.
- queueMicrotask is for batching or deferring work to the end of the current turn without delaying to the next task.
- setTimeout(fn, 0) runs in the next task—use it to yield or to run after the next paint.
- requestAnimationFrame runs before the next paint—use it for visual updates. Pick the right primitive so updates batch and the UI stays smooth.