The Browser Contract

Browser Rendering and the Event Loop in Practice

Parse, style, layout, paint and composite. Also layout thrashing, and where tasks, microtasks and requestAnimationFrame fit.

Lesson 0818 min read#rendering#event-loop#performance

On this page

  1. The frame budget
  2. The rendering pipeline
  3. The event loop in one page
  4. Layout thrashing
  5. Why scrolling and input get janky
  6. Layers, will-change and content-visibility
  7. How to debug it
  8. Common mistakes
  9. Interview questions
  10. Key takeaways
  11. What's next

In one line: the browser has one main thread. It runs your JavaScript and draws every frame. If your code keeps the thread busy for too long, the page cannot respond or paint. A smooth UI comes from short tasks and the cheapest kind of rendering work.

A thread is one line of work that a program can run. The main thread is the browser's single thread for your page: it runs JavaScript, handles clicks and draws the page. A frame is one picture of the page shown on the screen. Jank means stuttering: frames are late or missing, so motion looks uneven. The event loop is the browser's loop that picks the next piece of work from its waiting lines (queues) and runs it. The rendering pipeline is the list of steps that turns your HTML, CSS and JavaScript changes into pixels. The two are halves of one story. Interviewers like this topic because one simple model explains jank (stuttering), laggy scrolling and "why does my animation stutter?"

The frame budget

The frame budget is the time you have to prepare one frame. Most screens refresh 60 times a second (60 Hz), and 1000 ms divided by 60 is about 16.7 ms. So the browser has about 16.7 ms per frame (8.3 ms at 120 Hz) to run your code and make the pixels. If anything holds the main thread longer than that, a frame is dropped.

A task is one piece of work that the main thread runs from start to end without a break. A task that runs for more than 50 ms is called a long task. Long tasks make clicks feel slow to answer. INP (Interaction to Next Paint) is the metric that measures this delay. It is explained in Lesson 09.

The rendering pipeline

The pipeline is the list of steps the browser follows to turn your code into pixels. Think of a factory line with five stations:

TEXT
JavaScript ─▶ Style ─▶ Layout ─▶ Paint ─▶ Composite (your code)  (which   (size and  (fill     (combine layers,              CSS      position   pixels)    usually on the GPU)              applies) of boxes)
StageWhat it doesTriggered by changing…
StyleWorks out which CSS rules apply to each element (this is called style recalculation)Classes, attributes, :hover states
Layout (reflow)Works out the geometry: where every box goeswidth, height, top, margin, font-size, adding or removing nodes
PaintDraws text, colours, borders and shadows into layerscolor, background, box-shadow, border-radius
CompositeMoves and blends layers that are already painted. A layer is a separate painted picture of part of the page. The GPU is the graphics chip that is very fast at moving and blending picturestransform, opacity (and some filter effects)

The cost goes down as you move to the right. A change that only needs composite skips layout and paint. So it can stay smooth even when the main thread is busy.

ChangePipeline workCost
transform: translateX() / opacityComposite onlyCheap
background-color, box-shadowPaint + compositeMedium
width, height, top, left, marginLayout + paint + compositeExpensive, and can affect many other elements

This is why you animate with transform and opacity, and not with left and width.

Reflow vs repaint. Reflow (layout) works out the geometry again, and it often forces a repaint. Repaint draws the pixels again without changing the geometry. Reflow costs more. Changes that only use the compositor avoid both.

The event loop in one page

JavaScript runs on one thread. So work waits in queues, and the browser handles it in a loop. Think of a single cashier with a line of customers. Each person is served fully before the next one.

  1. Take one task (also called a macrotask) from the task queue. A queue is a waiting line: the first item in is the first item out. A task can be a click handler, a setTimeout callback, a network response or a message.
  2. Run it until it finishes.
  3. Run all microtasks until the microtask queue is empty. A microtask is a small piece of work that must run right after the current task, before anything else. Microtasks are promise callbacks, queueMicrotask callbacks and MutationObserver callbacks. (queueMicrotask(fn) adds fn as a microtask. MutationObserver is an object that tells you when the DOM changes.)
  4. If it is time to render (about every 16.7 ms), run the animation frame callbacks. requestAnimationFrame(fn) (often called rAF) is a browser function that runs fn just before the next paint. Then run style, layout and paint.
  5. Repeat.
JavaScript
console.log("1 sync");setTimeout(() => console.log("4 timeout (task)"), 0);Promise.resolve().then(() => console.log("3 promise (microtask)"));requestAnimationFrame(() => console.log("5 rAF (just before the next paint)"));queueMicrotask(() => console.log("3b microtask"));console.log("2 sync");// Typical order in Chrome: 1 sync, 2 sync, 3 promise, 3b microtask, 4 timeout, 5 rAF// Microtasks ALWAYS come right after the current script and before anything else.// The order of the timeout and the rAF callback is NOT guaranteed: a 0 ms timer usually fires// first, but if a frame is due, rAF can run before it. Never write code that depends on it.

What this means for you:

  • Microtasks run before the browser can render. A promise (an object for a result that arrives later) chain that keeps adding microtasks can starve rendering (and input). Starving means the browser never gets a turn. Difference in one line: a task is one turn of the loop, and a microtask is a small extra job that runs at the end of that turn.
  • setTimeout(fn, 0) makes a new task. So rendering can happen before it runs. This is how you yield (give the browser a turn).
  • Rendering happens between tasks, never in the middle of one. A 300 ms synchronous loop means no paint and no input handling for 300 ms.
  • requestAnimationFrame callbacks run right before the next paint. This is the right place to read or write values that affect layout during an animation.

(The JavaScript series explains the loop in more depth. Here we only look at how it shapes rendering.)

Layout thrashing

Layout thrashing means forcing the browser to redo layout again and again inside one task. The browser normally batches layout (it waits and does many changes at once). You change several things, and it works out the layout once. But there is a problem if you read a layout value (offsetHeight, getBoundingClientRect(), scrollTop, getComputedStyle) after a write that made the layout out of date. The browser must work out the layout right now to give you a correct answer. If you read, write, read, write in a loop, you force a full layout on every round. The repeated forced layout is what makes it slow.

JavaScript
// ❌ Layout thrashing: each iteration writes then readsfor (const el of items) {  el.style.width = box.offsetWidth / 2 + "px"; // read (forces layout), then write (invalidates it)}// ✅ Batch: all reads first, then all writesconst w = box.offsetWidth / 2;                  // one readfor (const el of items) el.style.width = w + "px"; // writes only, one layout later

Fixes:

  • Batch reads and writes. Read everything first. Then write everything.
  • Do the writes in requestAnimationFrame.
  • Use ResizeObserver (an object that tells you when an element's size changes) and IntersectionObserver (an object that tells you when an element enters or leaves the visible area). Do not check sizes and positions again and again (polling) in scroll handlers.
  • Avoid getBoundingClientRect() in loops that run often. Save the result.
  • In React, measure once in useLayoutEffect (a hook that runs right after React changes the DOM and before the browser paints). Do not measure each item during render.

Why scrolling and input get janky

Scroll handlers

JavaScript
// ❌ Heavy work on every scroll eventwindow.addEventListener("scroll", () => {  const top = el.getBoundingClientRect().top; // layout read  el.style.transform = `translateY(${top / 2}px)`;  updateStickyHeader(); // plus more work});

A scroll event is a signal the browser sends each time the page scrolls. It can fire more than once per frame. Here is a fix:

JavaScript
let ticking = false;window.addEventListener(  "scroll",  () => {    if (ticking) return;    ticking = true;    requestAnimationFrame(() => {      update();            // at most once per frame      ticking = false;    });  },  { passive: true },      // tell the browser you won't call preventDefault(), so it can scroll without waiting for you);
  • A passive listener is an event listener that promises never to call preventDefault(). Then the browser can scroll at once and does not wait for your code. Use { passive: true } on touchstart, touchmove, wheel and scroll listeners.
  • Use IntersectionObserver instead of scroll math that asks "is it visible?"
  • Use CSS when you can: position: sticky, scroll-snap and scroll-driven animations.

Long tasks and input delay

A click that arrives during a 200 ms task must wait 200 ms. So break the work into small pieces:

JavaScript
async function processInChunks(items, doItem) {  let last = performance.now();  for (const item of items) {    doItem(item);    if (performance.now() - last > 12) {              // keep each slice short      await (globalThis.scheduler?.yield?.() ?? new Promise((r) => setTimeout(r, 0)));      last = performance.now();    }  }}

Other tools:

  • scheduler.yield() is a function that pauses your code, lets the browser handle waiting input and painting, and then continues your code. It is not in every browser yet, so use setTimeout(0) as a fallback. Both give input a turn.
  • requestIdleCallback is a function that runs your code when the browser has nothing else to do. Use it for work that is really low priority.
  • A Web Worker is a separate background thread that runs your JavaScript. It has no access to the DOM and talks to the page by messages. Workers move heavy CPU work (parsing, sorting, diffing) off the main thread completely.
  • startTransition is a React function that marks an update as not urgent, so React can interrupt it to handle a click or key press first (Frontend Depth).

Layers, will-change and content-visibility

  • The browser puts some elements on their own compositor layer (a layer that the GPU can move without asking the main thread). These are elements with a transform or opacity animation, will-change, video or canvas. Such an element can move without repainting the others.
  • will-change: transform is a CSS property that tells the browser an element is about to change, so it can prepare. It is a hint. It is not a magic speed-up. Every layer uses memory. Add it just before an animation and remove it afterwards. Do not put it on everything.
  • content-visibility: auto lets the browser skip layout and paint for sections that are off screen. It does the work only when they come near the viewport. Use it with contain-intrinsic-size to avoid jumps in the scrollbar. It is great for long pages and feeds.
  • contain: layout paint is a CSS property that tells the browser that a part of the page does not affect the rest. This limits the damage of a change.

How to debug it

  1. Chrome DevTools is the set of debugging tools built into Chrome. In its Performance panel, record while you reproduce the jank. Look for long tasks (red corners), the Main thread flame chart (a chart where each bar is a function call, and wider bars took more time), and the purple Layout blocks and green Paint blocks.
  2. Look for repeated "Recalculate Style" and "Layout" inside a loop. That is thrashing. DevTools may show "Forced reflow."
  3. Turn on Rendering > Paint flashing and Layout Shift Regions to see what repaints.
  4. Layers and Rendering > FPS meter show compositing problems.
  5. Use CPU throttling (4x slowdown). Your laptop hides problems that mid-range phones show.

Common mistakes

  1. Animating top, left, width or height instead of transform or opacity.
  2. Mixing reads and writes in loops and event handlers (layout thrashing).
  3. Heavy synchronous work in event handlers. The UI freezes until it ends.
  4. Scroll and touch listeners that are not passive.
  5. Overusing will-change and making hundreds of layers.
  6. Thinking Promise means "async" and so "does not block." The callback still runs on the main thread. A long chain of microtasks still blocks rendering.
  7. Testing only on a fast desktop.

Interview questions

Reflow vs repaint?

Reflow (layout) works out the size and position of elements again, and it usually causes a repaint. Repaint draws the pixels again without changing the geometry. Reflow costs more and can spread to other elements. Changes to transform and opacity can avoid both and only need compositing.

Why does reading offsetHeight in a loop hurt?

If styles were changed earlier in the loop, the browser must run layout right away to give a correct value. If reads and writes are mixed, layout runs on every round. This is layout thrashing. I do all the reads first and then all the writes, ideally inside requestAnimationFrame.

Which CSS properties can animate on the compositor?

transform and opacity (and some filter effects in many browsers). They need no layout or paint when the element is on its own layer. So they stay smooth even if the main thread is busy.

Scenario: a scroll handler causes jank. Fix it.

I mark the listener passive. I limit the work to once per frame with requestAnimationFrame. I avoid layout reads inside it. I move visibility checks to IntersectionObserver. I use CSS (sticky, scroll-snap) where I can. Then I profile in the Performance panel to confirm that the long task or forced reflow is gone.

Where do microtasks, tasks and requestAnimationFrame fit in the event loop?

One task runs. Then all microtasks run until none are left. Then, if a frame is due, the animation-frame callbacks run, followed by style, layout and paint. Then the next task starts. So microtasks always run before rendering, and a long chain of microtasks can delay paint.

What is a long task and why does it matter?

A long task is a task that runs for more than 50 ms. While it runs, the main thread cannot handle input or paint. So users feel a delay. Long tasks cause a poor INP score. I break them up, yield with scheduler.yield or timeouts, or move the work to a Web Worker.

Does will-change make things faster?

Only when it is a targeted hint that an element is about to be animated. It lets the browser prepare a layer. If you use it too much, it wastes memory and can make things slower. I add it just before the animation and remove it afterwards.

Why can a Promise-heavy loop freeze the UI?

Promise callbacks are microtasks. All microtasks run before the browser may render or take the next task. If each callback adds another one, the queue never becomes empty, and rendering is starved. I use setTimeout or scheduler.yield to give control back to the browser.

Scenario: scrolling is janky only on a page with a sticky header and a parallax hero. How do you diagnose it?

I record the scroll in the Performance panel with CPU throttling. I look for long frames, and for layout and paint work on each scroll event. Common causes are a scroll handler that reads layout, and a parallax effect that animates top or background-position instead of transform. Others are large areas that get repainted, and listeners that are not passive. I move the effect to transform, limit updates with requestAnimationFrame, mark listeners passive, and promote only the layers that animate.

Key takeaways

  • One main thread runs your JavaScript and the rendering pipeline. Keep tasks under 50 ms and frames within about 16 ms.
  • Prefer animations that only need composite (transform, opacity) over properties that trigger layout.
  • Batch reads before writes to avoid layout thrashing.
  • Use requestAnimationFrame, passive listeners and observers instead of heavy scroll handlers.
  • Move heavy work to Web Workers, and yield inside long loops.
  • Measure with the Performance panel and CPU throttling. Do not guess.

What's next

Lesson 09: Core Web Vitals for Engineers. We turn "it feels slow" into numbers: LCP, INP and CLS. You will learn their limits, why INP replaced FID, and how field data differs from lab data.

Published by Jainik · Last updated · How these lessons are made

Keep learning

Related lessons

Part of the study guide

Related series