Detecting when React Components are Visually Rendered as Pixels<!-- --> | <!-- -->Web Performance Tips

Detecting when React Components are Visually Rendered as Pixels

Despite the name of React's most well known API, ReactDOM.render(...), React does not render! 😮

Rendering (the process of presenting visual elements on-screen) is owned by the browser; it's orchestrated by the C++ internal browser rendering pipeline.

React's job is to construct the DOM tree in a structure that will subsequently be used to generate the resulting rendered pixels. It itself simply mutates the internal browser data structures, and like all other JavaScript-based UI technologies, relies on the browser engine to facilitate positioning and drawing.

In this tip, I'll walk through a simple React hook that will notify your JavaScript code of when your React component is presented on screen for the user to see, and I'll explain how it works along the way.

Prerequisites

You should be familiar with performance timing markers and how to measure paint time via JavaScript.

Familiarity with the browser event loop is recommended as well.

One thing that's important to remember here is that the browser cannot visually represent any changes being prepared in JavaScript until after the JavaScript Task has completed:

A diagram showing that JavaScript must complete before a Frame can be produced

React's Job

React's job is to update the DOM and CSS Styles. This is done through JavaScript Tasks on the Main Thread.

A diagram showing React updating the DOM and CSS Styles, which are input to the browser rendering pipeline

The browser's job is to visually present the DOM and CSS styles to the user by delivering a frame. Once the React's JavaScript work has completed, the browser may start the process of rendering:

A diagram showing the browser ingest DOM and CSS Styles, which are processed by the browser to produce a Frame

Example Component

Consider the following React component that produces a list of items:

function ItemListComponent(props) {
    return (
        <ul>
            {
                props.items?.map(item => (
                    <li key={item.id}>{item.text}</li>
                ))
            }
        </ul>
    );
}

In this component, if there are props.items to present to the UI, React will interface with the DOM to apply the appropriate updates.

As performance minded engineers, we want to measure our web application's performance to track regressions and identify bottlenecks. As the author of this component, I would want to measure how long it takes to render on-screen for users to see visually.

Naïve Approach

Many engineers assume that when React props or state are updated, that the browser will immediately present the changes on-screen to the user.

As a result, they may author something like the following to measure when they believe the ItemListComponent is rendered on screen:

function ItemListComponent(props) {
    React.useEffect(() => {
        /**
         * If there are items in the list, use a naïve attempt to capture
         * the render time.
         */
        if (props.items?.length) {
            performance.mark('ListRendered');
        }
    }, [props.items]);

    return (
        <ul>
            {
                props.items?.map(item => (
                    <li key={item.id}>{item.text}</li>
                ))
            }
        </ul>
    );
}

This code above does not accurately capture whether or not the ItemListComponent is presented visually on screen.

Examining the Naïve Approach

You may be wondering: What time does this naïve approach capture?

The answer is: A point in time during the JavaScript Task when React is executing its update to the DOM.

Since the browser's Render Steps and JavaScript Tasks must share the same thread, React and all JavaScript work in the Task must completely finish before the browser can present the pixels to the user. This useEffect will run sometime well before the pixels are presented on screen:

A diagram showing the Main Thread, React Running, and where the naïve approach logs the mark

This is problematic, because often times, React has additional work to complete before it finalizes the JavaScript task. The browser also must drain and execute any queued microtasks, irrespective of if they are related to the React update being applied.

Furthermore, there could be task competition, and another JavaScript task could slip in on the Main Thread before the browser can produce the frame.

Leveraging Browser APIs

As discussed in my tip on capturing paint time, we can utilize the requestAnimationFrame and MessageChannel APIs to more accurately log when a DOM update is represented visually to the user.

To do this, we will use the following helper function:

/**
 * Runs `callback` shortly after the next browser Frame is produced.
 */
function runAfterFramePaint(callback) {
    requestAnimationFrame(() => {
        const messageChannel = new MessageChannel();

        messageChannel.port1.onmessage = callback;
        messageChannel.port2.postMessage(undefined);
    });
}

Note: In production code, we would need to adjust this callback to account for cases where the document.visibilityState is hidden and requestAnimationFrame won't fire. Production code would also need to handle cancellation if the React component is unmounted before this fires.

With our new helper setup, we can integrate it into a re-usable React Hook:

function useMarkFramePaint({ markName, enabled }) {
    useEffect(() => {
        /**
         * Only perform the log when the calling component has signaled it is
         * ready to log a meaningful visual update.
         */
        if (!enabled) {
            return;
        }

        // Queues a requestAnimationFrame and onmessage
        runAfterFramePaint(() => {
            // Set a performance mark shortly after the frame has been produced.
            performance.mark(markName);
        });
    }, [markName, enabled])
}

In our ItemListComponent, we can use this hook like this:

function ItemListComponent(props) {
    useMarkFramePaint({
        markName: 'ItemListRendered',
        /**
         * Signal to the hook that we want to capture the frame right after our item list
         * model is populated.
         */
        enabled: !!props.items?.length
    });

    return (
        <ul>
            {
                props.items?.map(item => (
                    <li key={item.id}>{item.text}</li>
                ))
            }
        </ul>
    );
}

With this hook integrated, we will queue a performance.mark() operation when props.items is populated. It will execute on the next frame produced after React JavaScript task completes:

A diagram showing the more accurate approach to capturing frame time with React on the Main Thread

A Real Example

Let's look at a real example of this. I have produced an example page here you can trace yourself.

In this example, I've integrated a button. This button, when clicked, will retrieve some remote JSON data, and update the DOM via React. I've simulated some delays in various places to simulate what I often see in production React applications. (Rarely is a React application a simple <ul>!)

A screenshot of the demo page

I've added performance timing markers to measure the two approaches described above when rendering our React-based list:

A screenshot of the Chromium Profiler highlighting both approaches and performance timing marks

In this view, you can see a few things:

  1. Once the DataFetch completes, React rendering begins.
  2. When we mark NaiveInaccurate_ItemListRender, the frame has not been delivered to the screen
  3. JavaScript completes and then the browser produces a frame (shown visually in the Frames section)
  4. Once the frame is produced, we mark it via FramePresented_ItemListRender

Conceptually, our trace can be represented like this (not exactly to scale):

A diagram representing the above trace

We can see that relying exclusively on React's lifecycle methods to measure performance does not accurately represent our user experience. Users care about frame delivery and on screen pixels, so our measurements should reflect that.

With this knowledge, ensure your performance timing markers are accurate in your React app!

That's all for this tip! Thanks for reading! Discover more similar tips matching Measuring.