Back to blog
Aug 2026·3 min readAI-generated

Reflows and Repaints: Why Your Page Feels Janky

#performance#browser#frontend

You measure an element's height with offsetHeight, use it to set another element's width, read that width back, and set a third element's height — and somehow this fifteen-line function is single-handedly tanking your frame rate. The culprit almost never shows up as "slow JavaScript" in a naive read of the code. It's the browser silently recalculating your entire page's layout, multiple times, because of the order you asked for things in.

What a reflow actually is

A reflow (also called layout) is the browser recomputing the geometry of the page — where every element sits, how big it is, how text wraps — because something changed that affects positions or sizes: a class toggle that changes width, a new element inserted into the DOM, even a font finishing its download. A repaint is cheaper: the geometry stayed the same, but pixels need to be redrawn because a color or shadow changed. Reflows are the expensive one, because a single layout change can cascade — resizing one box can shift everything below and beside it.

Browsers know this, so by default they batch layout work. If you change five style properties in a row, the browser doesn't recompute layout five times — it waits until the next frame, does one pass, and moves on.

The trap: reading layout properties

That batching breaks the moment you read a layout-dependent property after writing one. Properties like offsetHeight, offsetWidth, getBoundingClientRect(), and scrollHeight can only be answered correctly if the browser's internal layout is up to date — so reading one forces an immediate, synchronous recalculation, throwing away the batching optimization.

const box = document.querySelector('.box');

box.style.width = '300px';       // write
const height = box.offsetHeight; // read — forces a synchronous reflow right here
box.style.height = height + 'px'; // write

One write-then-read pair like this isn't catastrophic. The real damage happens when this pattern runs inside a loop:

const items = document.querySelectorAll('.item');

items.forEach((item) => {
  item.style.width = '100px';         // write
  const height = item.offsetHeight;   // read — forces reflow, every iteration
  item.style.height = `${height * 2}px`;
});

This is called "layout thrashing": write, read, write, read, each read forcing the browser to redo layout work it just did, over and over, once per item. A hundred items means a hundred forced synchronous reflows for something that should have been one.

Fixing it: batch reads, then batch writes

The fix is almost mechanical once you see the pattern: separate all your reads from all your writes.

const items = document.querySelectorAll('.item');

// Read phase — collect everything first
const heights = Array.from(items).map((item) => item.offsetHeight);

// Write phase — apply everything after
items.forEach((item, i) => {
  item.style.width = '100px';
  item.style.height = `${heights[i] * 2}px`;
});

Now there's exactly one batch of reads (the browser can answer all of them from the layout it already has, since nothing changed in between) and one batch of writes (which the browser can defer and coalesce into a single reflow before the next paint).

Scheduling writes with requestAnimationFrame

For layout changes driven by things like scroll or resize handlers, wrap the write in requestAnimationFrame so it lands right before the browser's own paint cycle instead of competing with it:

let ticking = false;

window.addEventListener('scroll', () => {
  if (ticking) return;
  ticking = true;

  requestAnimationFrame(() => {
    const scrollY = window.scrollY; // read
    header.style.transform = `translateY(${Math.min(scrollY, 100)}px)`; // write
    ticking = false;
  });
});

The ticking flag prevents queuing more than one pending frame's worth of work — without it, a fast scroll can queue dozens of callbacks that all fire back-to-back.

Prefer properties that skip layout entirely

Some CSS properties can be animated without triggering layout or even paint — the browser can hand them straight to the compositor. transform and opacity are the two that matter most in practice:

/* Triggers layout on every frame — avoid animating this */
.slide-in {
  transition: left 0.3s ease;
}

/* Compositor-only — cheap regardless of how large the element is */
.slide-in {
  transition: transform 0.3s ease;
}
// Instead of animating `left`, `top`, `width`, or `height`...
el.style.left = '200px';

// ...animate transform, which doesn't touch layout at all
el.style.transform = 'translateX(200px)';

For a moving or fading element, this is usually a drop-in swap and it's the single highest-leverage change you can make to a janky animation.

Finding thrashing you didn't know you had

DevTools' Performance panel flags this directly — recorded reflows show up as purple "Layout" bars, and Chrome specifically calls out "Forced reflow" in the console when a script triggers one, with a stack trace pointing at the exact line that read a layout property mid-batch. Record a few seconds of the interaction that feels slow and look for a sawtooth pattern of purple bars; a flat stretch of yellow (script) with no purple until the end of the frame means you're not thrashing, you're just doing slow computation, which is a different problem with a different fix.

Takeaway

The browser is happy to batch layout work for you — until you read a geometry property mid-batch and force it to catch up early. Separate reads from writes, defer writes to requestAnimationFrame when they're driven by continuous events, and reach for transform/opacity over left/width/height wherever the visual effect allows it. None of this requires a framework or a library; it's entirely about the order operations happen in.

NewerBuilding a Simple State Machine in TypeScriptOlderUnderstanding HTTP Caching Headers