Layout Thrashing and Forced Reflows<!-- --> | <!-- -->Web Performance Tips

Layout Thrashing and Forced Reflows

The browser's style and layout process (also known as reflow) is responsible for assigning visual styles and geometry to elements of a web application. It is one of the most expensive computational operations performed during the lifecycle of a web application, yet many web developers introduce code that forces it to run more often than it needs to!

When JavaScript codepaths force the browser to perform the reflow process outside of its usual cadence, it's called a forced reflow. When codepaths perform a forced reflow multiple times in quick succession within a frame, it's known as layout thrashing.

In this tip, we'll discuss forced reflows, layout thrashing, how to spot it in your application, as well as techniques for mitigating the performance impact.

Prerequisites

I recommend familiarizing yourself with my tips on the browser rendering pipeline and the browser event loop before reading this.

A Normal (Asynchronous) Reflow

In order to present pixels on screen, the browser must compute styles (i.e. colors, font family, etc.) and compute high precision floating point screen coordinates for each visual element to draw.

It typically runs once per frame, right before pixels are about to be presented on screen. It is initiated by the browser, and run on the main thread.

A diagram of the main thread, denoting Style and Layout phases as Reflow and occurring before Frame Paint

If reflow runs during this phase, it's known as asynchronous reflow, because it was not forced to run synchronously outside of the scheduled rendering phase.

A Forced Reflow

Certain JavaScript Browser APIs can call into the Style and Layout Engines to acquire styling information and precise coordinates of elements. For example, the Element.getBoundingClientRect() API synchronously returns the bounding box and coordinates from the underlying Layout Engine primitive:

const element = document.getElementById('item1')

const rect = element.getBoundingClientRect();

console.log(rect);

/*
Prints a DOMRect:
{
    width: 1525.823,
    height: 999.999,
    top: 7.997,
    right: 1553.821,
    bottom: 1007.1231
    left: 7.997
}
*/

If the coordinates or styles for the requested element have not been computed yet, the browser must immediately compute them via performing reflow. This immediate invocation of the Style and Layout engines to resolve undetermined coordinates is forced, therefore referred to as a forced reflow (or synchronous reflow).

A diagram showcasing the difference in timings between synchronous and asynchronous reflow

In an actual profiler trace, forced reflows will surface like this in your flamegraphs.

A screenshot of the Chromium F12 Profiler with blocks of synchronous Layout highlighted

If you hover over one of these tasks, you'll see a warning stating: Forced reflow is a likely performance bottleneck.

A screenshot of the Chromium F12 Profiler with blocks of synchronous block hovered showing a UX warning

Aside: Consider studying the Chromium source code for the getBoundingClientRect() API to see where reflow gets invoked!

Layout Tree Caching and Incremental Design

When a web page initially loads, the DOM is parsed and every element is without visual styles or positioning information. The browser's Style and Layout Engines must compute styles and geometry for each visual element before drawing them on screen.

The browser works as hard as possible to minimize the cost of reflow on your web application. It does this by storing and caching the styles and positions of each visual element in a browser-internal data structure known as the Render Tree (or Layout Tree) after a successful style and layout pass.

A diagram showing the browser's render tree and coordinates

Let's consider a simple static HTML web page.

  1. The HTML is parsed into the DOM. All elements are un-positioned and un-styled.
  2. The browser enacts asynchronous reflow, assigning styles and coordinates to each visual element
  3. The styles and coordinates are built up into the Render Tree and Styles cache for subsequent access

Once styles and coordinates are cached, subsequent access to styling and geometry information is extremely fast. In our example, once asynchronous reflow is completed, if we had a script that called getBoundingClientRect() on an Element, the results will be retrieved from cache:

A diagram showing the getBoundingClientRect API retrieving coordinates from cache

Invaliding the Layout Tree

Many web applications are not static HTML web pages. Instead, they utilize client side JavaScript (like React) to create and update visual elements.

When client side JavaScript code mutates the DOM, it can add, remove, or update elements, often directly affecting the Render Tree, thereby often invaliding previously cached styles and coordinates.

When JavaScript adds elements to the DOM, they are inserted without styling positioning information. That is because reflow typically runs asynchronously, right before a frame paints. This takes place at some point on the main thread, after JavaScript tasks have completed.

For example, consider adding a modal to the DOM. When the HTML elements for the modal are inserted into the DOM, they are originally un-positioned:

const modalRoot = document.createElement('div');
modalRoot.classList.add('modal--root');

const subDiv = document.createElement('div');
const paragraph = document.createElement('div');
// Add other DOM nodes and styles as needed...

document.body.firstChild.appendChild(modalRoot);

// DOM nodes are added!

A diagram showing un-styled DOM nodes added to the DOM tree but not in the Render Tree

This mutation invalidates (a portion of) the cached Render Tree. Later, when asynchronous reflow occurs, each DOM node is assigned styles and positions before being displayed on screen:

A diagram of the main thread invoking asynchronous reflow

When this completes, this will result in an updated and cached Render Tree:

A diagram of the render tree with newly provisioned Render Object nodes

Note that the entire tree is not usually entirely recomputed -- the browser tries to only recompute the minimal subset of the tree affected by the addition. In this way, reflow is incremental.

The scope of the invalidation applied to the Render Tree will vary based on the size and type of DOM mutation (i.e. adding a single element, updating a class, removing multiple DOM nodes, etc.).

Synchronous Reflow

We've discussed how JavaScript codepaths can invalidate the Render Tree, and how JavaScript APIs can query the underlying Layout Engine primitives (i.e. getBoundingClientRect()).

Combining these two concepts, let's illustrate a piece of JavaScript code that invalidates the Render Tree and forces a reflow:

const element = document.getElementById('modal-container');

element.classList.add('width-adjust'); // 1. invalidate Layout Tree
element.getBoundingClientRect(); // 2. force a synchronous reflow. This can be SLOW!

We can see here that the we first perform a mutation by updating the classes for a DOM element. This operation invalidates a subset of the Layout Tree, marking the node as dirty. It's positioning and styling information is no longer accurate and need to be recomputed.

A diagram of the render tree with a node marked as dirty

The problem here is in the subsequent operation, calling getBoundingClientRect() on the invalidated DOM node. This will invoke a synchronous reflow because we've asked the browser to get the position of a dirty / un-positioned element. It must be positioned immediately in order to satisfy the request, as it doesn't have accurate information available.

A diagram of the getBoundingClientRect API experiencing a cache miss when querying for coordinates

From a thread perspective, this will extend the duration of the JavaScript task, potentially creating a Long Task.

A diagram of the Main Thread running a synchronous reflow within a JavaScript task

Once synchronous reflow is completed, the browser will cache this information in the Render Tree for subsequent access. Assuming no other updates to the Render Tree occur, the browser will likely have no dirty elements to reposition, and asynchronous reflow may be reduced (or skipped altogether!).

A diagram of the Main Thread showcasing that synchronous reflow may reduce the cost of asynchronous reflow

Forced reflow does not always manifest a performance issue. In some cases, it simply shifts the reflow cost to run during the JavaScript task, rather than the asynchronous reflow phase (as that can utilize the cached output of the synchronous reflow).

Regardless, unintentional synchronous reflows are generally a good idea to avoid if possible; we'll discuss why, next.

Layout Thrashing

If synchronous reflows are invoked multiple times within a single Task / Frame, what's observed is called Layout Thrashing.

Consider the following code:

const elements = [ ...document.querySelectorAll('.some-class') ];

// In a loop, force a reflow for each element :(
for (const element of elements) {
    element.classList.add('width-adjust'); // 1. invalidate Layout Tree
    element.getBoundingClientRect(); // 2. force a synchronous reflow. This can be SLOW!
}

We can see here that we are invalidating the Render Tree, then forcing a reflow multiple times within a Task.

Depending on the size of the invalidation (for example, if you are invalidating a large portion of the Render Tree), this can manifest as a serious performance issue!

In this case, we are forcing the browser to run the expensive synchronous reflow operation more times than necessary for a single frame:

A diagram of the Main Thread showcasing multiple forced reflows and layout thrashing in a single task

Unlike a regular synchronous reflow, layout thrashing not only shifts the timing of reflow, but also the number of times layout is invoked which can create long tasks and degrade frame rate.

Preventing Layout Thrashing

You will want to avoid Layout Thrashing in all cases. There are a few well known general strategies for preventing it!

Batching Reads and Writes

Reading positioning and styling information can be quite fast if performed on a fully cached, styled and positioned Render Tree.

Writing positioning information will mark nodes as dirty, but won't force the browser to perform reflow immediately.

If we can leverage these two facts, we can convert code above that that thrashes the layout engine:

const elements = [ ...document.querySelectorAll('.some-class') ];

// Do all reads
const rects = elements.map(element => element.getBoundingClientRect());

// Do all writes
elements.forEach(element => element.classList.add('width-adjust'));

// Done! Asynchronous reflow will compute positions later.

This way, the expensive reflow cost is only going to happen once, during asynchronous reflow.

Tip: The requestAnimationFrame API can help orchestrate your reads if you have to perform them after writes.

Be Aware of Layout Inducing APIs

When authoring your web application, you should be aware of the various browser APIs that can force reflow. You don't need to avoid them at all costs, but you should be mindful of when these are invoked, and profile appropriately to ensure you aren't causing unexpected synchronous reflows.

React Development

By using React you are not protected from Layout Thrashing. All web applications can abuse the browser's layout engine, even those written in modern web frameworks.

React's declarative syntax adds a layer of indirection into when the actual DOM mutations (and Layout invalidations) occur. This can make spotting Layout Thrashing harder when reading through code.

In my experience, Layout Thrashing occurs in useEffect hooks trying to measure DOM nodes or other React components. Typical use cases for performing this type of measuring include positioning a Tooltip or restoring Scroll Position.

function MyComponent() {
    const elementRef = React.useRef();

    // Be careful with Layout APIs in `useEffect`!
    React.useEffect(() => {
        // When does this run? Before or After DOM updates?
        const rect = elementRef.getBoundingClientRect();

        // do something with `rect`
    }, [])

    return (
        <div ref={elementRef}>
        {
            /* more DOM nodes... */
        }
        </div>
    );
}

If you are unsure if your React useEffect callback is forcing synchronous reflow, you can collect a trace and search in the flamegraph for the callstack that forces the reflow.

The React team has provided a specialized hook, useLayoutEffect, which can use to read positioning information after React has flushed its mutations to the DOM:

function MyComponent() {
    const elementRef = React.useRef();

    // Use the `useLayoutEffect` hook instead if you are forcing reflow.
    React.useLayoutEffect(() => {
        // This will run after React has flushed DOM updates.
        const rect = elementRef.getBoundingClientRect();

        // Use the values read from `rect`
        // But writing to the DOM here will likely cause more reflow!
    }, [])

    return (
        <div ref={elementRef}>
        {
            /* more DOM nodes... */
        }
        </div>
    );
}

Conclusion

We discussed the nuanced lifecycle of reflow in the browser, including asynchronous reflow, synchronous reflows, and layout thrashing.

As web developers, we must be mindful of when and how the browser's underlying Style and Layout Engines are engaged to ensure web applications are leaning into the highly optimized incremental design provided by the browser, rather than against it.

This will ensure we provide a consistent and optimal frame rate and user experience.

That's all for this tip! Thanks for reading! Discover more similar tips matching Browser Internals and JS Optimization.