Fixing layout thrashing in React: a mobile-first performance guide

What layout thrashing is, why low-end phones feel it most, and how to fix it by batching layout reads and writes.

When you build mobile-first, especially for low-end devices, performance isn’t a nice-to-have. It’s survival. One of the most overlooked causes of a sluggish interface is layout thrashing: it quietly burns CPU time, causes jank and drains the battery.

This post covers what layout thrashing is, why it hurts most on cheap phones, and how to fix it by batching layout work with requestAnimationFrame, with a working React example.

What layout thrashing is

Layout thrashing happens when code interleaves DOM reads and writes, forcing the browser to recalculate the layout several times within a single frame.

The bad pattern: reads and writes interleaved

for (const box of boxes) {
  const height = box.offsetHeight;        // read: forces a layout
  box.style.height = `${height + 10}px`;  // write: invalidates it
}
  • Each write invalidates the layout.
  • Each read that follows a write forces the browser to recalculate it, right there.
  • Over a thousand elements, not unusual in a long list or a table, that’s a thousand layouts where one would do.

Why it’s worse on low-end devices

  • Slower CPUs: every layout takes longer.
  • Weaker GPUs: every repaint that follows takes longer too.
  • Smaller batteries: every wasted cycle costs charge.

On a high-end laptop you might get away with it. On a cheap phone, the interface stutters and the battery drains.

The fix: all the reads, then all the writes

The fix is to separate layout reads from writes. Read everything first, while the layout is still valid, then make every change. However many elements there are, the browser now recalculates the layout once instead of once per element.

A React example: batched layout with requestAnimationFrame

import { useEffect, useRef } from 'react';

export function BatchedBoxes() {
  const boxRefs = useRef([]);

  useEffect(() => {
    const frame = requestAnimationFrame(() => {
      // 1. read everything
      const heights = boxRefs.current.map((box) => box.offsetHeight);

      // 2. then write everything
      boxRefs.current.forEach((box, i) => {
        box.style.transform = `translateY(${heights[i]}px)`;
      });
    });

    return () => cancelAnimationFrame(frame);
  }, []);

  return (
    <div>
      {[...Array(5)].map((_, i) => (
        <div
          key={i}
          ref={(el) => { boxRefs.current[i] = el; }}
          style={{ margin: '10px', padding: '20px', background: '#eee', transition: 'transform 0.3s ease' }}
        >
          Box {i}
        </div>
      ))}
    </div>
  );
}

What’s happening

  • requestAnimationFrame schedules the work just before the next paint.
  • All the heights are read first, in one pass: one layout at most.
  • Then all the transforms are written in one go. transform doesn’t affect layout, so the writes don’t invalidate it again.
  • The effect cancels the frame if the component unmounts first.

This avoids layout thrashing and keeps animations smooth, even on low-end phones.

Why requestAnimationFrame

requestAnimationFrame runs a function right before the browser’s next repaint, which makes it the natural place for layout work:

  • it follows the browser’s rendering loop, so your changes land in the very next frame;
  • it gives every component one predictable moment for its reads and writes, instead of scattering them across event handlers;
  • it pauses in background tabs, so it spends no CPU or battery on frames nobody sees.

Think of it as telling the browser:

I’ll do my layout work just before you paint, so I don’t get in your way.

Real-world impact

On low-end phones, batching layout work can:

  • reduce input lag;
  • make scrolling smoother;
  • lower CPU use;
  • extend battery life.

Layout thrashing is a good example of how a little algorithmic thinking pays off: one pass of reads followed by one pass of writes, instead of a layout per element. It isn’t just clever code. It’s respecting the limits of the devices people actually use.