The event loop and frames
Tasks, microtasks, and rendering opportunities: when the browser can paint and what stops it. Measured in Chrome: the order callbacks run in, real frames, and a long task's gap.
- 01Predict the orderSay when a script, a timer, a promise and a frame callback run, and why microtasks come before the next task.
- 02Follow one frameExplain when the browser gets a chance to draw, what it does in each frame, and how long a frame lasts at 60 and 120 Hz.
- 03See what a long task costsShow, with frames Chrome recorded, how one long task holds up every frame and every tap until it ends.
One loop, one thread
Asha is on the Leaf and Kettle page again. Near the top, a small tea leaf drifts across the banner. A script moves it a tiny step in every frame. A frame is one picture on the screen. The screen shows many of them each second, like the pages of a flip book.
Asha taps Add to cart. The shop's click handler is slow: it keeps the browser busy for 300 ms. For that moment, the leaf stops. Then it carries on.
The course made a copy of this banner and tapped its Add to cart button in Chrome. Here is what the screen showed:
While the click handler ran, the leaf stayed on the dashed line. It moved on only once the handler had finished.
Why did the leaf stop? The click had nothing to do with the leaf. And how can a slow script stop the drawing at all?
To find out, the course built a small test page that does the same two things. A function moves things in every frame. Halfway through, one job keeps the browser busy for 300 ms. The course recorded the page in Chrome 156. Here is one frame from that recording:
// One frame on a test page, as Chrome recorded it. Times are in milliseconds (ms).const frame = { began: 1519.5, callbackRan: 1811.3 }; console.log(`The frame began at ${frame.began} ms.`);console.log(`Its callback ran at ${frame.callbackRan} ms.`);console.log(`It waited ${(frame.callbackRan - frame.began).toFixed(1)} ms.`);- Line 1 is a comment. Times are in milliseconds (ms), counted from when the page started loading. A millisecond is a thousandth of a second.
- Line 2 holds two times.
beganis when the frame began.callbackRanis when the page's code for that frame ran. That code is a frame callback: a function the page asks the browser to run just before it draws a frame. - Lines 4 to 6 print
The frame began at 1519.5 ms.,Its callback ran at 1811.3 ms.andIt waited 291.8 ms.
Most frames on this page ran their callback about 2 ms after they began. This one waited almost 300 ms. While it waited, nothing on the page moved. Something was in the way. The rest of this lesson finds out what.
The answer starts with lesson 6.1. One thread, the main thread, runs almost everything a page does: its JavaScript, its styles, its layout and its paint. A thread can only do one thing at a time. So the main thread needs a rule for what to do next. Lesson 6.1 compared it to a head cook. Here is how she picks her next dish:
Orders reach the kitchen as paper tickets. They hang on a rail in the order they came in. The cook takes the oldest ticket and makes that order from start to finish. Only then does she take the next one. If one order takes ten minutes, every ticket behind it waits ten minutes.
- In real life: The cook
- In the browser: The main thread
- In real life: One ticket: one order to make
- In the browser: One task, like running a script or a click handler
- In real life: The rail of tickets, oldest first
- In the browser: A task queue
- In real life: Take the oldest ticket, finish it, take the next
- In the browser: The event loop
Now the same thing in the browser's words. A task is one job the main thread runs from start to end. Running a script is a task. So is a timer's callback, and so is a click handler. Tasks wait in a task queue, in the order they arrived. The event loop is the rule the main thread follows, over and over:
- Take the oldest task from a task queue.
- Run it, all the way to the end.
- Run every microtask that is waiting. A microtask is a small follow-up job. The next section explains them.
- Go back to step 1.
There's no step for drawing. That's on purpose. In the HTML Standard, drawing a new frame is a task too. When the screen is ready for a new picture, the browser queues a task called update the rendering. It waits its turn, like the click and the timer. Here is the whole loop in one picture:
The main thread runs one task at a time, then every waiting microtask. Drawing a frame is a task too, so it can only happen between other tasks. A long task holds up every frame, and every tap, until it ends.
That already answers Asha's question. Her leaf's frame was a task, stuck behind a 300 ms task. The next sections go one step at a time: microtasks first, then when frames come, what happens inside one, how long it may take, and what a long task does. The JavaScript lesson Rendering & the event loop shows the loop with small programs. This lesson times it in Chrome.
Tasks and microtasks
Start with the simplest case, a timer. setTimeout(fn, 0) asks the browser to run fn after 0 ms. Does that mean right away?
setTimeout(() => console.log("The timer's callback ran."), 0);
console.log("The script finished.");- Line 1 sets a timer with a delay of 0 ms. The arrow function is its callback: a function you hand over for someone else to run later. When the delay is up, the browser queues a task that will run it.
- Line 2 prints
The script finished. - Then the timer's task gets its turn, and prints
The timer's callback ran.
The script itself is a task. The main thread finishes it before it takes the next one. A delay of 0 doesn't mean "now". It means "queue a task as soon as you can". Press Visualize to watch the timer's task wait in its queue.
Tasks come from many places. MDN lists these:
- Running a
<script>as the page loads. - A click: the browser makes a task that runs the click handlers.
- A timer from
setTimeout()orsetInterval(), when its time is up.
A browser can keep more than one task queue. The HTML Standard's example is one queue for clicks and key presses, and one for everything else. The browser picks which queue to serve next, so it can serve clicks and key presses first.
Microtasks run before the next task
Some follow-up jobs don't wait for the next turn of the loop at all. Back in the kitchen:
Some tickets come with a sticky note: "while you're at it, warm the cups". The cook does every note on a ticket before she takes the next ticket. If a note says "and add a note to warm the plates", she does that one too. Only when no notes are left does she reach for the rail.
- In real life: A sticky note on the ticket
- In the browser: A microtask, like a promise callback
- In real life: Doing every note before the next ticket
- In the browser: The microtask checkpoint after each task
- In real life: A note that adds another note
- In the browser: A microtask that queues another microtask
- In real life: The next ticket on the rail
- In the browser: The next task, such as a timer
A microtask is a small job that runs as soon as the current task ends, before the loop takes the next task. Three kinds are common:
- the callback you pass to a promise's
.then(); - the rest of an
asyncfunction, after anawait; - a function you pass to
queueMicrotask().
Microtasks wait in their own line, the microtask queue. After every task, the loop empties it. The HTML Standard calls that a microtask checkpoint. Here is a promise next to a timer:
setTimeout(() => console.log("The timer ran: a new task."), 0);Promise.resolve().then(() => console.log("The promise callback ran: a microtask."));console.log("The script finished.");- Line 1 sets a timer. Its callback will run in a new task, later.
- Line 2 makes a promise that has already succeeded, with
Promise.resolve(). So its.then()callback goes straight into the microtask queue. - Line 3 prints
The script finished. - The script's task ends, so the loop empties the microtask queue. It prints
The promise callback ran: a microtask. - Only then does the loop take the timer's task:
The timer ran: a new task.
The promise was queued after the timer, but it ran first. A microtask always beats the next task.
What if a microtask queues another microtask? MDN says the loop "will keep calling microtasks until there are none left in the queue, even if more keep getting added." Here it is:
setTimeout(() => console.log("The timer ran."), 0);queueMicrotask(() => { console.log("The first microtask ran."); queueMicrotask(() => console.log("The microtask it added ran too."));});console.log("The script finished.");- Line 1 sets a timer.
- Lines 2 to 5 queue a microtask with
queueMicrotask(). When it runs, line 3 printsThe first microtask ran.Then line 4 queues a second microtask. - Line 6 prints
The script finished. - The rest comes out as
The first microtask ran.,The microtask it added ran too.andThe timer ran.
The second microtask didn't exist when the script ended. It still ran before the timer. That's the note that adds a note.
Why it matters: microtasks are quick to start, but they still use the main thread. They all run before the next task, and drawing a frame is a task. MDN warns that microtasks which keep adding microtasks can keep the loop busy "endlessly".
Now add a frame callback to the mix. The course ran the five lines below from one task in Chrome, five times. Every run went script, promise, queueMicrotask, setTimeout, requestAnimationFrame. You can predict the first three. The last two are a race: the timer waits for its task's turn, and the frame callback waits for the next frame. Run it a few times in your own browser:
setTimeout(() => log("setTimeout"), 0);requestAnimationFrame(() => log("requestAnimationFrame"));Promise.resolve().then(() => log("promise"));queueMicrotask(() => log("queueMicrotask"));log("script");Run the five lines in your browser. Your click is the task that queues them all.
Rendering opportunities and vsync
So when does the frame's turn come? Not whenever the page likes. The screen sets the beat.
A screen shows a new picture at a fixed rate, its refresh rate. It's counted in hertz (Hz), which means times a second. A 60 Hz screen shows 60 pictures a second, one every 16.7 ms. Each picture is a frame, one page of the flip book.
The display sends a signal for each new picture. That signal is called vsync. Chrome's source says the call to begin each frame "normally" comes "just after a vsync interrupt".
The HTML Standard calls each chance to show a new frame a rendering opportunity. What it says about them:
- They depend on "hardware refresh rate constraints and user agent throttling for performance reasons."
- At 60 Hz, they come "at a maximum of every 60th of a second (about 16.7ms)."
- A page that can't keep up might get 30 a second instead.
At each rendering opportunity, the browser notes the time and queues an update-the-rendering task. In the kitchen:
A waiter passes the kitchen hatch on a steady round, once a minute. Each time, he clips a ticket to the rail: "plate up the next course". The cook gets to it when she finishes the ticket in her hands. If she's quick, the plate goes out on time. If she's stuck on a big order, the plate waits, and his next rounds bring nothing new to the table.
- In real life: The waiter
- In the browser: The compositor thread from lesson 6.1
- In real life: His steady round, once a minute
- In the browser: The screen's refresh: every 16.7 ms at 60 Hz
- In real life: Each pass by the kitchen hatch
- In the browser: A rendering opportunity
- In real life: His ticket: "plate up the next course"
- In the browser: The update-the-rendering task
- In real life: The plate on the table
- In the browser: A new frame on the screen
In Chrome, the waiter is the compositor thread from lesson 6.1. Chromium's How cc works describes the hand-off:
- The screen's beat reaches the compositor thread.
- If the page has asked for a frame, for example with
requestAnimationFrame(), the compositor thread sends the main thread a message to begin a frame. The message carries the frame's time. - The main thread runs the frame callbacks, then the rest of the frame's work, and hands the result back.
If the main thread is slow to answer, the docs say, the compositor "may draw without waiting" for it.
How steady is the beat? The course's Chrome ran headless, with no real screen, and still kept a steady beat. Here are six frames in a row from the test page:
// When six frames in a row began, in ms, as Chrome recorded them.const frames = [1436.1, 1452.8, 1469.4, 1486.1, 1502.8, 1519.5]; const frame = (frames[5] - frames[0]) / 5;console.log(`A new frame began every ${frame.toFixed(1)} ms.`);console.log(`That's ${Math.round(1000 / frame)} frames a second.`);- Line 2 holds when each of six frames began, in a row.
- Line 4 finds one gap. From the first frame to the sixth there are five gaps, so it divides by 5.
- Line 5 prints
A new frame began every 16.7 ms.Line 6 printsThat's 60 frames a second.
Why it matters: the beat doesn't wait for the page. A frame that isn't ready on one beat can only go out on a later one, 16.7 ms or more later.
requestAnimationFrame timing
The leaf moves because its page asks for a frame callback. requestAnimationFrame() asks the browser "to call a user-supplied callback function before the next repaint", in MDN's words. The next repaint is the next frame.
Here is the frame callback from the course's second test page, the grid page. The grid is a box of 2,000 small squares. Each frame, the callback makes the box a little wider, so the squares move to new places:
// The grid page's frame callback. grid is a box of 2,000 small squares.let width = 300;const grow = () => { width = width >= 700 ? 300 : width + 4; grid.style.width = width + "px"; requestAnimationFrame(grow);};requestAnimationFrame(grow);- Line 1 is a comment.
gridis the box, found earlier on the page. - Line 2 starts the width at 300 pixels.
- Lines 3 to 7 make
grow, the frame callback. Line 4 adds 4 pixels to the width, and goes back to 300 after 700. Line 5 gives the box its new width. - Line 6 asks for
growagain, for the next frame. Line 8 asks for the first frame.
Each call to requestAnimationFrame() books one callback, for one frame. MDN calls it "one-shot". That's why line 6 asks again. Without it, the grid would grow once and stop.
So what does the browser do inside that frame? The course recorded the grid page with Chrome's tracing on. A trace is a detailed log of everything the browser did, step by step, with times. From it, the course took one typical frame:
// The steps of one frame on the grid page, in the order Chrome's trace shows them.const steps = ["Frame callbacks", "Recalculate style", "Layout", "Pre-paint", "Paint", "Layerize", "Commit"];console.log(`First: ${steps[0]}.`);console.log(`Then: ${steps.slice(1).join(", ")}.`);- Line 2 lists the frame's steps on the main thread, in the order the trace shows them.
- Line 3 prints
First: Frame callbacks. - Line 4 prints
Then: Recalculate style, Layout, Pre-paint, Paint, Layerize, Commit.
Here is what each step does:
- Frame callbacks: Chrome runs
grow, and any otherrequestAnimationFrame()callbacks. - Recalculate style: it works out which CSS rules apply to each element, and their final values.
- Layout: it works out where each square goes, and how big it is. The box is wider, so squares move.
- Pre-paint: it marks what needs painting again.
- Paint: it makes a list of what to draw, and in what order.
- Layerize: it groups what was painted into layers, parts of the page drawn separately.
- Commit: it hands the result to the compositor thread.
The next lesson, The pixel pipeline, looks at style, layout, paint and layers in detail. For now, notice the order. The frame callbacks run first. So the new width from line 5 is styled, laid out and painted in this same frame. The HTML Standard's update-the-rendering steps have the same order: "run the animation frame callbacks", then "Recalculate styles and update layout".
Each callback also gets a timestamp: the time its frame began. The HTML Standard sets it to the moment of the rendering opportunity. MDN calls it "the end time of the previous frame's rendering". It's the began from the start of the lesson. On the test page, a callback usually ran 2.2 ms after its frame's timestamp.
Why it matters: make visual changes in a frame callback. In web.dev's Optimize JavaScript execution, Paul Lewis calls requestAnimationFrame() "the only way to guarantee that your JavaScript will run at the start of a frame". A setTimeout() callback runs "at some point in the frame, possibly right at the end". Then the page can miss a frame. It's like handing the waiter a plate just after he has left the hatch.
Frame budgets at 60 and 120 Hz
How much time does one frame give you? A frame budget is how long one frame lasts: 1000 ms divided by the refresh rate. MDN says 60 Hz is the most common refresh rate, "though 75hz, 120hz, and 144hz are also widely used."
for (const hz of [60, 75, 120, 144]) { console.log(`At ${hz} Hz, a frame lasts ${(1000 / hz).toFixed(1)} ms.`);}- Line 1 goes through four refresh rates, one at a time.
- Line 2 divides 1000 ms, one second, by the rate. It prints
At 60 Hz, a frame lasts 16.7 ms.andAt 75 Hz, a frame lasts 13.3 ms. - Then
At 120 Hz, a frame lasts 8.3 ms.andAt 144 Hz, a frame lasts 6.9 ms.
A frame budget works like a bus timetable. The bus leaves every 16.7 ms, full or not. Work that isn't ready waits for the next bus.
Not all of the budget is yours, either. Paul Lewis explains why in web.dev's Rendering performance. At 60 Hz, he says, "the browser has its own overhead for each frame, so all of your work needs to be completed inside 10 milliseconds." Miss it, and "the frame rate drops, and page contents judder on-screen." That judder is called jank.
How much of its budget did the grid page use? Here are the times of its frame's seven steps:
// How long each of the frame's seven steps took, in ms, in the same order.const steps = [0.1, 0, 0.8, 0.1, 0.5, 0, 0]; const work = steps.reduce((sum, ms) => sum + ms, 0);console.log(`The seven steps took ${work.toFixed(1)} ms in all.`);console.log(`That's ${Math.round((work / (1000 / 60)) * 100)}% of a frame at 60 Hz.`);console.log(`And ${Math.round((work / (1000 / 120)) * 100)}% of a frame at 120 Hz.`);- Line 2 holds how long each step took, in the same order as the list above.
- Line 4 adds them up. Line 5 prints
The seven steps took 1.5 ms in all. - Lines 6 and 7 compare that with one frame. They print
That's 9% of a frame at 60 Hz.andAnd 18% of a frame at 120 Hz.
The grid page fits easily. But look at the second share. The same work takes twice the share of a 120 Hz frame. On a faster screen, the budget halves.
A faster screen changes animations too. The grid page adds 4 pixels every frame. A 120 Hz screen has twice as many frames a second, so the grid would grow twice as fast. MDN warns about this: use the timestamp to work out how far to move, "otherwise, the animation will run faster on high refresh-rate screens."
Measure your own screen. The playground records each frame's timestamp for two seconds, as the course's test page did. Then try it with a long task in the middle:
not yetnot yetnot yetnot yetMeasure your own screen. The playground writes down each frame's timestamp for two seconds. Then try it with a long task halfway.
Why a long task freezes everything
Back to Asha's leaf, and the frame that waited 291.8 ms. web.dev's Optimize long tasks puts the rule plainly: "The main thread can only process one task at a time. Any task that takes longer than 50 milliseconds is a long task."
The test page had one. Halfway through its two seconds of frames, a timer started a task that kept the main thread busy for 300 ms, like a slow click handler. Chrome recorded it:
// The test page's long task, as Chrome recorded it. Times are in ms.const task = { start: 1511.3, duration: 300 }; console.log(`The task started at ${task.start} ms.`);console.log(`It ended at ${task.start + task.duration} ms.`);- Line 2 holds when the task started, and how long it ran.
- Line 4 prints
The task started at 1511.3 ms.Line 5 printsIt ended at 1811.3 ms.
Now line these times up with the frame from the start of the lesson:
- At 1511.3 ms, the long task starts.
- At 1519.5 ms, 8.2 ms later, a rendering opportunity comes. The frame's update-the-rendering task has to wait its turn.
- At 1811.3 ms, the long task ends. The frame's callback runs at that same moment.
The frame wasn't lost. It waited behind the long task, like the waiter's ticket behind the cook's big order. And the frames that should have come during the task? Look at when the next frame began:
// When the frames on either side of the long task began, in ms.const before = 1519.5;const after = 1802.8; const gap = after - before;console.log(`The gap between them: ${gap.toFixed(1)} ms.`);console.log(`${Math.round(gap / 16.7) - 1} frames never came.`);- Lines 2 and 3 hold when the frames on either side of the task began.
- Line 5 works out the gap. Line 6 prints
The gap between them: 283.3 ms. - Line 7 divides the gap by one frame, 16.7 ms. That makes about 17 frames. The 17th is the frame at 1802.8 ms itself, so it takes 1 away. It prints
16 frames never came.
283.3 ms is 17 beats of a 60 Hz screen, almost exactly. The frames stayed on the screen's beat. The 16 beats in between got no frame at all:
Draw a tea leaf in the corner of every page of a notebook, a little further along each time. Flip the pages, and the leaf drifts smoothly. Now tear out 16 pages from the middle. Flip again: the leaf stops, then jumps ahead.
- In real life: One page of the flip book
- In the browser: One frame
- In real life: Flipping at a steady speed
- In the browser: The screen's refresh rate
- In real life: The pages torn out
- In the browser: The 16 frames the long task skipped
- In real life: The leaf stopping, then jumping
- In the browser: Jank
That's what Asha saw. If the leaf's script places it by the time, as MDN advises, it jumps to where it should be by now. Either way, it stops first.
Taps wait too. A click handler runs in a task. If Asha taps during the long task, her tap's task waits in the queue behind it. The compositor thread can still scroll the page meanwhile, as lesson 6.1 showed, because scrolling doesn't need the main thread. But the leaf is moved by a script, on the main thread, so it waits.
You can find long tasks in your own page. The Long Tasks API reports them as they happen. On the test page, a PerformanceObserver watching for longtask entries reported the task, 300 ms long.
Why it matters: a frame can only be drawn between tasks. So keep tasks short. web.dev: "you can break up a long task into several smaller ones." Then the loop gets a turn between them, frames keep coming, and taps get answered. The Responsiveness: INP stage shows how.
Task, microtask, or frame?
Every callback in this lesson waits in one of three places. Read each row from left to right: what you write, what it queues, and when it runs.
| When you write | What it queues | When it runs |
|---|---|---|
setTimeout(fn, 0) | It queues a task when the delay is up. | It runs when the loop picks that task, after the current task and its microtasks. |
| A click handler | The click queues a task that runs it. | It runs when the loop picks the click's task. |
promise.then(fn) or await | It queues a microtask. | It runs as soon as the current task ends, before the next task. |
queueMicrotask(fn) | It queues a microtask. | It runs with the other microtasks, before the next task. |
requestAnimationFrame(fn) | It books a frame callback for the next frame. | It runs at the start of the next frame, before style, layout and paint. |
Now sort some callbacks yourself:
- A
setTimeout()callback - A click handler
- Running a
<script>as the page loads - A promise's
.then()callback - The code after an
await - A
queueMicrotask()callback - A
requestAnimationFrame()callback - Working out the layout after the frame callbacks
Sort each callback by when the event loop runs it.
Common mix-ups
- "setTimeout(fn, 0) runs right away." No. It queues a task. That task waits until the current task and all its microtasks are done.
- "A promise callback is a task, like a timer." No. It's a microtask. It ran before the timer in every recorded run.
- "requestAnimationFrame runs every 16.7 ms." No. It follows the screen. On a 120 Hz screen, frames come every 8.3 ms.
- "The browser draws a frame after every task." No. It draws at rendering opportunities, at most once per refresh. Many tasks can run between two frames.
- "A long task stops for a moment to let a frame through." No. A task always runs to its end. Updating the rendering is a task too, so it waits. During the 300 ms task, 16 frames never came.
- "A long task only slows itself down." No. Every frame and every tap behind it waits too.
- "Microtasks are too small to freeze a page." No. The loop runs microtasks until none are left. Microtasks that keep adding microtasks can keep it busy endlessly.
Practice exercises
Answer first, then use the hints.
This time, the timer's callback queues a microtask of its own. What does the last line printed say?
setTimeout(() => {
console.log("The timer ran.");
queueMicrotask(() => console.log("The timer's microtask ran."));
}, 0);
console.log("The script finished.");The timer's microtask ran. comes last. The script prints first. Then the timer's task runs and queues a microtask. As soon as that task ends, the loop runs the microtask.
The list now has 90 Hz where 75 Hz was. What does the second line say?
for (const hz of [60, 90, 120, 144]) {
console.log(`At ${hz} Hz, a frame lasts ${(1000 / hz).toFixed(1)} ms.`);
}At 90 Hz, a frame lasts 11.1 ms. One second is 1000 ms, and a 90 Hz screen splits it into 90 frames.
Two timers this time, and the first one queues a microtask. What does the third line printed say?
setTimeout(() => {
console.log("The first timer ran.");
queueMicrotask(() => console.log("The first timer's microtask ran."));
}, 0);
setTimeout(() => console.log("The second timer ran."), 0);
console.log("The script finished.");The first timer's microtask ran. The loop runs microtasks after every task, not only after the script. So the first timer's microtask runs before the second timer gets its turn.
On a 60 Hz screen, this code moves the leaf 2 pixels each frame: 120 pixels a second. How many pixels a second does it move on a 120 Hz screen?
const leaf = document.querySelector("#leaf");
let x = 0;
function moveLeaf() {
x += 2;
leaf.style.transform = `translateX(${x}px)`;
requestAnimationFrame(moveLeaf);
}
requestAnimationFrame(moveLeaf);const leaf = document.querySelector("#leaf");
let start;
function moveLeaf(timestamp) {
start ??= timestamp;
const x = (timestamp - start) * 0.12; // 0.12 px a millisecond: 120 px a second
leaf.style.transform = `translateX(${x}px)`;
requestAnimationFrame(moveLeaf);
}
requestAnimationFrame(moveLeaf);240. The callback runs once per frame, so a faster screen runs it more often: 120 × 2 = 240 pixels a second, twice as fast. The fix uses the frame's timestamp. The leaf's place depends on how much time has passed, so it moves 120 pixels a second on any screen.
Download the grid page's trace and open it in DevTools' Performance panel. On the Main track, find one of the short tasks that repeat about every 16.7 ms. This lesson calls the first step inside it the frame callbacks. What does DevTools call it?
Animation frame fired. It's DevTools' name for the trace event FireAnimationFrame: the step where Chrome runs the requestAnimationFrame() callbacks. Inside it, a Function call runs grow.
What happens to requestAnimationFrame() callbacks when the page is in a background tab? Look it up on MDN.
They pause, in most browsers, in background tabs and hidden iframes. MDN says it's done to improve performance and battery life. The HTML Standard also skips updating the rendering for a hidden page. So keep the playground's tab in front while it measures.
Check your understanding
For each question, ask: is this a task, a microtask, or part of drawing a frame? And what is it waiting for?
Question 1 of 8What does the microtask chain print last?
Read the code, then predictsetTimeout(() => console.log("The timer ran."), 0); queueMicrotask(() => { console.log("The first microtask ran."); queueMicrotask(() => console.log("The microtask it added ran too.")); }); console.log("The script finished.");Choose an answer to see the explanation.
Question 2 of 8What does the event loop do right after each task?
Choose an answer to see the explanation.
Question 3 of 8How does drawing a new frame fit into the event loop?
Choose an answer to see the explanation.
Question 4 of 8At 60 Hz, how often can the browser draw a frame at most?
Choose an answer to see the explanation.
Question 5 of 8When do requestAnimationFrame callbacks run?
Choose an answer to see the explanation.
Question 6 of 8Why move an animation in a requestAnimationFrame callback rather than with setTimeout(fn, 16)?
Choose an answer to see the explanation.
Question 7 of 8What does the last line of the missing-frames example print?
Read the code, then predict// When the frames on either side of the long task began, in ms. const before = 1519.5; const after = 1802.8; const gap = after - before; console.log(`The gap between them: ${gap.toFixed(1)} ms.`); console.log(`${Math.round(gap / 16.7) - 1} frames never came.`);Choose an answer to see the explanation.
Question 8 of 8In the recording, why did the timer run before the frame callback every time?
Choose an answer to see the explanation.
Key takeaways
- The main thread runs one task at a time: a script, a timer's callback, a click handler.
- After each task, the loop runs every microtask, even ones added on the way. Promise callbacks and awaits are microtasks.
- The screen sets the beat. At 60 Hz, a rendering opportunity comes at most every 16.7 ms. At 120 Hz, every 8.3 ms.
- Drawing a frame is a task too. Its frame callbacks run first, then style, layout and paint.
- At 60 Hz, keep your work for each frame inside about 10 ms.
- A long task holds up every frame and every tap. During a 300 ms task, 16 frames never came.
Remember the one-liner.
The browser draws only between tasks, at most once a refresh: keep tasks short, and frames keep coming.
Sources
The facts on this page come from these specs, docs and source files:
- Event loop processing model, HTML Standard, WHATWG.
- Using microtasks in JavaScript with queueMicrotask(), MDN.
- begin_frame_source.h: where Chrome's frames start, Chromium source.
- How cc works, Chromium docs.
- Window: requestAnimationFrame() method, MDN.
- Blink paint README, Chromium source.
- Optimize JavaScript execution, Paul Lewis, web.dev.
- Rendering performance, Paul Lewis, web.dev.
- Optimize long tasks, Jeremy Wagner and Brendan Kenny, web.dev.
- Styles.ts: the Performance panel's names for trace events, Chrome DevTools source.
Coming next: The pixel pipeline, also in How the browser loads and renders. Style, layout, paint and composite, and which changes trigger which steps.