How the Browser Draws a Frame
You write this:
card.style.height = "320px";
On the next line, nothing on screen has changed. The browser hasn’t worked out where anything goes, and it hasn’t drawn anything. It has noted that the page needs to be redone, and it will get to it later.
Hard to believe? On a normal page, “later” comes a few milliseconds after that line, too fast to see. So this button runs the line, then a loop that keeps the page busy for one second, as if the code after it were slow. Watch the card during that second. When the loop ends, the demo reads offsetHeight twice, to show when layout really ran:
Interactive demo: a button runs card.style.height = "320px", blocks the main thread for a second, then reads offsetHeight. Try it on the page.
.cardWatch the card while the main thread is blocked.
The demo raises more questions than it answers. Why did the browser wait for the loop, instead of drawing as soon as the height changed? What does it do once it gets its turn? And why did the first read cost something, and the second nothing? Answering them takes the words that come up any time a page feels slow: task, frame, style, layout, paint, composite, reflow. Once you know what each one means, most rendering bugs read like a story you already know.
One Thread, Taking Turns#
Most of what a page does happens on one thread, the main thread. MDN puts it this way: “the browser uses a single thread to run all the JavaScript in your page, as well as to perform layout, reflows, and garbage collection.” It’s also where the browser “processes user events and paints.”
One thread means one thing at a time. While your click handler runs, the browser isn’t drawing, and it isn’t handling the next click. Everything else waits for its turn.
Browsers do move some of the work to other threads: turning the drawing into pixels, and putting the final frame together, as Chrome’s Inside look at a modern web browser shows. That matters later in this post. Your JavaScript, though, runs on the main thread unless you put it in a
worker.
Tasks Run to the End#
The main thread’s work comes in tasks. Running a script is a task. So is each click, each setTimeout callback, each message from a worker. The event loop takes one task, runs it, and only then picks the next one.
Nothing stops a task halfway. If your handler loops for two seconds, the next task waits two seconds. A task that runs longer than 50 ms is called a long task.
Right after a task’s script finishes, before the next task, the browser runs the microtasks the script queued: promise callbacks and queueMicrotask(). They’re part of the same turn, not a new one.
Frames: When the Browser Gets to Draw#
Your screen refreshes at a fixed rate, most often 60 times a second, so about every 16.7 ms. Higher refresh rates are common too: at 120 Hz, it’s every 8.3 ms. Each refresh is a chance to show a new picture of the page, a frame.
The HTML spec calls that chance a rendering opportunity. It “typically” comes at regular intervals, and the browser decides when. A page that can’t keep up might get 30 a second instead of 60, and a hidden tab doesn’t get any.
When an opportunity comes and something on the page has changed, the browser renders. In the spec, rendering is a task of its own: when an opportunity comes, the browser queues a rendering task. So the event loop from the last section looks like this:
Task queues
- Input
click,keydown - Timers
setTimeout,setInterval - Rendering one task per rendering opportunity
- Pick one task The browser chooses the queue.
- Run it to the end Nothing else runs meanwhile.
- Run all microtasks Promise callbacks,
queueMicrotask()
Then back to the first step.
That’s what happened in the demo at the top. The click was one task from the input queue, and the one-second loop ran inside it, so the rendering task sat in its queue until the loop ended.
Rendering waits in line like any other task, and it can only run between two tasks. When a task runs long, refreshes pass without a frame:
The bottom row is what a frozen page is. The display keeps refreshing, but the browser has nothing new to show it, because the thread that would make the next frame is busy. At 60 Hz, a frame has 16.66 ms in total. The browser needs some of that time for its own work, so web.dev suggests keeping yours under 10 ms.
What Happens in a Frame#
When the browser renders, it runs a fixed list of steps. Most of them you’ll rarely think about. The ones that matter here, in order:
requestAnimationFramecallbacks (rAF for short). This is the last chance to change the page before this frame is drawn.- Style. The browser works out which CSS rules apply to which elements, and the final value of every property.
- Layout. From those values, it works out the geometry: the size and position of every box.
- Paint. It works out what to draw and in what order: backgrounds, borders, text, shadows.
- Composite. It puts the painted parts together, in the right order, into the frame you see.
The spec names the first three and leaves the drawing to the browser. The names for the last two come from web.dev’s pixel pipeline.
Follow the line from the start of this post through them. When card.style.height = "320px" runs, the browser only notes that style and layout are out of date. The rest waits for the next frame:
rAF callbacks
The callbacks you passed to
rAFrun.The last chance to change the page before this frame is drawn.
main thread
Style
Which rules match, and the final value of each property.
.card {
height: 320px;
}main thread
Layout
The size and position of every box.
main thread
Paint
What to draw, in order: a paint record.
1 card background
2 card border
3 card text
4 footer…main thread
Composite
Turn the record into pixels, and put the layers together.
can run on other threads
Paint doesn’t fill in pixels yet. In Chrome’s words, it produces a paint record, a note like “background first, then text, then rectangle”. Turning that into pixels is called rasterizing, and it can happen on other threads, together with compositing.
Not Every Change Runs Every Step#
Each step only needs to run if something it depends on changed. web.dev describes three cases:
heightStyleLayoutPaintCompositecolorStyleLayoutPaintCompositetransformStyleLayoutPaintComposite- Geometry, like
width,height,top, orfont-size, changes where boxes go, so it needs layout, and then paint. - Paint-only properties, like
color,background-image, orbox-shadow, change how something looks but not where it is. Layout is skipped. transformandopacitycan skip both. The browser takes what it already painted and moves it, scales it, or fades it while it composites. For that, the element needsits own compositor layer. You can ask for one with
will-change, but the runningtransformanimation in the demo below didn’t need it.
The last case is the one you can see. Compositing can run off the main thread, so a CSS animation of
transform can keep going while your JavaScript blocks it. An animation of left needs layout on every frame, and layout runs on the main thread:
Interactive demo: two boxes animate, one with left and one with transform, and a button blocks the main thread for a second. Try it on the page.
lefttransformStart the animations, then block the main thread.
When the block ends, the left box jumps to where it should be by then. Its animation kept time. It just had nobody to draw it.
That’s why animation advice says “animate transform and opacity”. They’re cheaper per frame, and they keep moving when the main thread is busy. It also means a smooth animation doesn’t prove the page is responsive. A spinner can spin on a page that ignores every click.
Forced Reflow: Layout Right Now#
Layout normally waits for the frame. But if you read a value that depends on layout, like card.offsetHeight right after setting the height, the browser runs style and layout right away to give you the right number. That’s a forced reflow. (“Reflow” is
MDN’s name for layout.)
Two things to keep in mind:
- It runs style and layout, not paint. Paint still waits for the frame. Reading
offsetHeightmakes the browser work out where things go, but nothing new reaches the screen until your task ends. The demo at the top showed this: its first read ran the layout that the write had left waiting. After that, nothing was out of date, so the second read just returned the number. - One is fine. Many add up. A forced layout costs what a normal one would. The trouble starts when a loop writes, reads, writes, reads, and makes the browser lay out the page on every turn.
The Short Version#
- The main thread runs your JavaScript and most of the rendering, one task at a time.
- A task runs to the end. Microtasks run right after it, in the same turn.
- The browser renders between tasks, at a rendering opportunity: usually once per display refresh, and only if something changed.
- A frame runs
requestAnimationFramecallbacks, then style, layout, paint, and composite. - Geometry changes run every step. Color changes skip layout.
transformandopacitycan skip layout and paint, and keep animating while the main thread is busy. - Reading a layout value after a change forces style and layout right away. It doesn’t paint.
Here’s one to try that on. A click handler shows a spinner, runs two seconds of work, then hides it again:
button.addEventListener("click", () => {
spinner.hidden = false;
processRows(rows); // About 2 seconds of synchronous work.
spinner.hidden = true;
});
The spinner never shows. Why? And how would you get it on screen before the work starts? You have most of the pieces now, but the obvious fixes don’t all work. I Set the Spinner Before the Work. Why Doesn’t It Show? tries them one at a time.