How the Browser Draws a Frame

7 min read

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:

.card

Watch the card while the main thread is blocked.

The card only grows once the second is over. Layout waited too: a second after the write, the first offsetHeight read is the one that takes time, because it runs layout. The second read has nothing left to do.

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
  1. Pick one task The browser chooses the queue.
  2. Run it to the end Nothing else runs meanwhile.
  3. Run all microtasks Promise callbacks, queueMicrotask()

Then back to the first step.

The event loop, as the HTML spec describes it. Rendering is one more queue. The browser only picks the next task, from whichever queue it chooses, after the current one and its microtasks are done.

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:

Drawn to scale at 60 Hz, not measured. With short tasks, the main thread is free at every refresh, so each one gets a frame. With one 70 ms task, four refreshes pass while it runs, and the screen shows the same picture until the task ends.

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:

  1. requestAnimationFrame callbacks (rAF for short). This is the last chance to change the page before this frame is drawn.
  2. Style. The browser works out which CSS rules apply to which elements, and the final value of every property.
  3. Layout. From those values, it works out the geometry: the size and position of every box.
  4. Paint. It works out what to draw and in what order: backgrounds, borders, text, shadows.
  5. 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:

  1. rAF callbacks

    The callbacks you passed to rAF run.

    The last chance to change the page before this frame is drawn.

    main thread

  2. Style

    Which rules match, and the final value of each property.

    .card {
      height: 320px;
    }

    main thread

  3. Layout

    The size and position of every box.

    card · 320pxfooter, moved down

    main thread

  4. Paint

    What to draw, in order: a paint record.

    1 card background
    2 card border
    3 card text
    4 footer…

    main thread

  5. Composite

    Turn the record into pixels, and put the layers together.

    layers → frame

    can run on other threads

The card from the start of the post, through one frame. Style works out that its height is 320px. Layout makes the card taller and moves the footer below it down. Paint lists what to draw, in order. Compositing turns that into pixels and puts the layers together into the frame.

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:

The steps each kind of change runs. Changing geometry runs everything. Changing a color skips layout, because nothing moved. A transform can skip layout and paint.
  • Geometry, like width, height, top, or font-size, changes where boxes go, so it needs layout, and then paint.
  • Paint-only properties, like color, background-image, or box-shadow, change how something looks but not where it is. Layout is skipped.
  • transform and opacity can skip both. The browser takes what it already painted and moves it, scales it, or fades it while it composites. For that, the element needs its own compositor layer. You can ask for one with will-change, but the running transform animation 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:

left
transform

Start the animations, then block the main thread.

Both boxes run the same CSS animation. One moves with left, one with transform. The block button runs a loop on the main thread for a second.

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.)

Not to scale. Reading offsetHeight right after the write moves style and layout into your task, so the task takes longer. Paint doesn't move: it still waits for the next frame.

Two things to keep in mind:

  • It runs style and layout, not paint. Paint still waits for the frame. Reading offsetHeight makes 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 requestAnimationFrame callbacks, then style, layout, paint, and composite.
  • Geometry changes run every step. Color changes skip layout. transform and opacity can 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.

← All posts