The JavaScript Event Loop in Depth: Tasks, Microtasks and Rendering Order
We work through output-order puzzles to see the task and microtask queues, where promises and queueMicrotask fit, when requestAnimationFrame runs in the rendering step, and why long tasks freeze the page.
Version note: The code in this post targets ES2026 and current browsers (Chrome, Edge, Firefox, Safari).
Predict First
Five lines, three different kinds of timing. In what order does this code print to the console?
console.log("1");
setTimeout(() => console.log("2"), 0);
Promise.resolve().then(() => console.log("3"));
queueMicrotask(() => console.log("4"));
console.log("5");
Make a guess before you keep reading and write the order down somewhere. We gave setTimeout zero milliseconds, and still it won't be the first to run. We'll see why, step by step, once we've opened up the engine's queues one by one.
The Big Picture: One Cook, Two Queues
Remember the basic picture we saw earlier: JavaScript runs on the main thread with a single call stack. Timers, network requests and user events wait in the browser's own machinery; when they're ready, the callbacks to run get in line. We called the mechanism that manages this line the event loop. In the Intermediate series we looked at it as a single "callback queue." In reality there's more than one queue, and the difference in priority between them is what this whole post is about.
Picture a restaurant kitchen with a single cook. Order tickets pile up at the door; the cook takes one ticket and doesn't move on until that dish is done. Those are tasks. The cook also has little sticky notes on the counter: "stir the sauce," "warm the plate." After finishing a ticket, the cook handles every note before taking the next ticket; if a new note gets written while handling one, that one gets done too. Those are microtasks. The waiter carries plates out to the dining room at regular intervals, but nothing gets served while the cook is at the stove. That's the rendering step.
The loop described by the HTML specification works roughly like this:
// one turn of the event loop (simplified)
// 1. Take the oldest task from a task queue and run it to completion.
// 2. Drain the microtask queue completely (including anything added meanwhile).
// 3. If it's time to update the screen, run the rendering step:
// requestAnimationFrame callbacks, then style, layout, paint.
// 4. Go back to the start.
Keep these four lines in mind and you can solve every puzzle in this post.
Tasks: setTimeout, Events and Friends
The specification simply calls these tasks; the community often calls them macrotasks to tell them apart from microtasks. Same thing. Task sources include setTimeout and setInterval callbacks, user events such as clicks and key presses, messages arriving through postMessage, and notifications about data coming from the network or disk. And the initial script itself: a <script> on the page, or the code you paste into the console, runs from start to finish like a task.
Here's the critical rule: the event loop runs one task per turn, and nothing can cut in until that task is finished. setTimeout(fn, 0) doesn't mean "run this after zero milliseconds"; it means "after at least zero milliseconds, put a task in the queue." Its turn doesn't come until the tasks and microtasks ahead of it are done.
There are other cases where zero isn't really zero. The specification requires timers nested more than five levels deep to wait at least 4 milliseconds. In background tabs, browsers slow timers down to as little as once per second to save energy. And there's no ordering guarantee between different task sources: the browser may, for example, give user input higher priority than a timer. What you can rely on is that timers set with the same delay run in the order they were set.
Microtasks: Promise Callbacks and queueMicrotask
Three main things go into the microtask queue: promise then, catch and finally callbacks, the continuation of code after an await, and functions you add directly with queueMicrotask. The callback of MutationObserver, which watches DOM changes, is a microtask too; we'll see it in upcoming posts.
The most important property of microtasks is that they run until the queue is completely empty. If a microtask adds a new microtask, that one also runs in the same turn, before the next task:
setTimeout(() => console.log("timeout"), 0);
Promise.resolve().then(() => {
console.log("micro 1");
queueMicrotask(() => console.log("micro 2"));
});
// micro 1
// micro 2
// timeout
await follows the same rule. An async function runs fully synchronously up to its first await; await suspends the function there and schedules the rest as a microtask. Try this in the console:
async function run() {
console.log("A");
await null;
console.log("B");
}
console.log("C");
run();
console.log("D");
// C, A, D, B
When run() is called, A is printed immediately, because nothing has been awaited yet. At the await null line the function pauses and control returns to the caller, so D is printed. When the script ends, the microtask queue is drained and B comes out. We gave await null rather than a promise; it doesn't matter, await wraps every value in a promise first.
Solving the Puzzles Step by Step
Now we can go back to the opening question. The correct order is 1, 5, 3, 4, 2. Let's go step by step. The script itself is a task. 1 is printed right away. setTimeout sends its callback to the task queue. Promise.resolve().then attaches to an already resolved promise, so its callback goes straight into the microtask queue. queueMicrotask adds its function right behind it. 5 is printed and the script ends. Now the microtask queue is drained: first 3, then 4. The queue is empty; the event loop takes the next task and 2 is printed.
Let's try a harder one. Predict first, then paste it into the console:
console.log("start");
setTimeout(() => {
console.log("timeout 1");
Promise.resolve().then(() => console.log("micro in timeout"));
}, 0);
setTimeout(() => console.log("timeout 2"), 0);
Promise.resolve()
.then(() => console.log("then 1"))
.then(() => console.log("then 2"));
console.log("end");
Output: start, end, then 1, then 2, timeout 1, micro in timeout, timeout 2. Two points matter here. First, then 2 wasn't in the queue while the script ran; it entered the queue when then 1 finished, but since draining continues until the microtask queue is empty, it still ran before the timers. Second, micro in timeout squeezed in between the two timers. timeout 1 was a task; the moment that task finished, the microtask queue was drained, and only then did timeout 2 get its turn. So microtasks are drained not only at the end of the script but at the end of every task.
The Rendering Step and Where requestAnimationFrame Fits
The browser doesn't paint the screen after every task. It waits for a rendering opportunity tied to the display's refresh rate; on a 60 Hz screen that's roughly every 16.7 milliseconds. When that moment comes, between two tasks, it runs the rendering step: first the callbacks registered with requestAnimationFrame, then style calculation, layout and paint. If the tab is in the background, this step usually doesn't run at all, so requestAnimationFrame callbacks wait as well.
The first consequence is surprising: as long as a task is running, the visual changes you make don't reach the screen. Try this in the console and watch the page closely:
document.body.style.background = "red";
const end = performance.now() + 2000;
while (performance.now() < end) {}
document.body.style.background = "";
The page freezes for two seconds, but you never see red. The background turned red, stayed that way for two seconds, then went back; but all of that happened inside a single task, and the waiter never got a chance to go out to the dining room. By the time the rendering step came, the background had already gone back to its old color.
requestAnimationFrame is neither a task nor a microtask; it's a separate list that runs in the next rendering step, right before paint. That's why it's the right home for animation code: it runs exactly once per frame, just before drawing. But that "before drawing" detail also sets a trap:
document.body.style.background = "red";
requestAnimationFrame(() => {
document.body.style.background = ""; // red is never painted
});
// To run after a frame has been painted, nest it twice:
// requestAnimationFrame(() => requestAnimationFrame(() => { ... }));
Because the first callback runs before paint, red still never makes it to the screen. If you want to be sure a frame has actually been painted and do something after it, you call requestAnimationFrame twice, nested: the first lands in this frame, the second in the next one.
One last note: if you set up setTimeout(fn, 0) and requestAnimationFrame(fn) at the same time, there's no guarantee which runs first. It depends on where you are within the current frame. Don't write code that relies on that order.
Why Long Tasks Freeze the UI
Turn the event loop's rule around and the cause of UI freezes becomes obvious: while a task is running, no other task, no rendering and no response to the user's click is possible. Browsers call tasks that take longer than 50 milliseconds long tasks; that's about where users start to feel the delay. Save the file below as freeze.html and open it in your browser:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Long task</title>
</head>
<body>
<p>Counter: <span id="counter">0</span></p>
<button id="block">Freeze for 3 seconds</button>
<button id="chunked">Do the same work in chunks</button>
<script>
const counter = document.querySelector("#counter");
let ticks = 0;
setInterval(() => {
ticks += 1;
counter.textContent = ticks;
}, 100);
function busy(ms) {
const end = performance.now() + ms;
while (performance.now() < end) {}
}
document.querySelector("#block").addEventListener("click", () => {
busy(3000);
});
document.querySelector("#chunked").addEventListener("click", async () => {
for (let i = 0; i < 60; i++) {
busy(50);
await new Promise((resolve) => setTimeout(resolve, 0));
}
});
</script>
</body>
</html>
When you press the first button, the counter stops for three seconds and then jumps ahead. Meanwhile, try selecting text on the page or pressing the second button; nothing happens, events are only handled once the task is over. The second button does the same three seconds of work in total, but split into 50-millisecond chunks. Because it moves to a new task with setTimeout after each chunk, the browser can paint, update the counter and respond to clicks in the gaps. The work takes a little longer, but the page stays alive.
Common Pitfalls
Trying to give the UI a breather with a microtask. If you write await Promise.resolve() instead of setTimeout in the chunked loop above, the page freezes again. Microtasks are drained before the task ends, before rendering, so no gap ever opens. To hand the main thread back to the browser, you need a new task.
Building an endless loop out of microtasks. A function that keeps rescheduling itself with setTimeout doesn't freeze the page, because each round is a separate task and rendering can happen in between. A function that reschedules itself with queueMicrotask never lets the queue empty; the tab locks up completely.
Assuming events triggered from code run in the same order as real ones. Try this in the console:
const button = document.createElement("button");
button.textContent = "Click me";
document.body.append(button);
button.addEventListener("click", () => {
Promise.resolve().then(() => console.log("micro 1"));
console.log("listener 1");
});
button.addEventListener("click", () => {
Promise.resolve().then(() => console.log("micro 2"));
console.log("listener 2");
});
button.click();
// listener 1, listener 2, micro 1, micro 2
Now click the button that was added at the bottom of the page with your mouse. This time the order is listener 1, micro 1, listener 2, micro 2. On a real click, the browser calls each listener separately, and because the call stack empties after each one, the microtasks run right away. button.click(), on the other hand, is called from inside your script; the stack never empties, so the microtasks wait until the script finishes. That's why tests that trigger events from code can behave differently from real use.
A Small Experiment
★☆☆ Predict before running: Promise.resolve().then(() => console.log(1)).then(() => console.log(3)); Promise.resolve().then(() => console.log(2)).then(() => console.log(4)); Do the two chains run one after the other, or do they interleave?
★★☆ Predict the order of the output, then try it: setTimeout(() => console.log("A"), 0); (async () => { console.log("B"); await null; console.log("C"); setTimeout(() => console.log("D"), 0); })(); Promise.resolve().then(() => console.log("E")); console.log("F"); Next to each line, note which queue it goes into.
★★★ In freeze.html, replace the setTimeout line in the chunked loop with await Promise.resolve() and press the second button. Why does the counter freeze again? Then change the same line to await new Promise((resolve) => requestAnimationFrame(resolve)); how long does the work take, how does the page behave, and why?
Now we know exactly when a promise callback runs. In the next post we apply that knowledge to real problems: the four ways to combine multiple promises, the new helpers that came with ES2024 and ES2025, cancellable functions, retry, timeouts and limiting how many jobs run at once.