Detecting when the Browser Paints Frames in JavaScript<!-- --> | <!-- -->Web Performance Tips

Detecting when the Browser Paints Frames in JavaScript

Do your performance measurements capture when the browser renders pixels to your user?

Most likely, not! In my experience, most developers capture Render Time metrics at some arbitrary time when React or other JS code is constructing DOM.

It's not surprising, as the browser doesn't provide an API to notify developers that a Paint event occurred, other than for the initial Frame paints on page load.

In this tip, we'll cover how to measure when pixels appear on the screen at any point in your application's lifecycle, and how it works under the hood.

Prerequisites

Why capture Paint time?

Capturing Paint time measures one of the most important scenarios for your users -- the UI is presented on their screen!

A diagram showing pixels appearing after Paint

It can also help highlight any additional time or inefficiencies between your DOM updates and when the Frame is produced.

A diagram showing Frame Times of varying length

Inefficiencies between DOM update Tasks and Frames may include:

  • Long Render Steps time - you may have due a complex UIs with many elements and expensive visual properties
  • Long JS Tasks - your JS DOM update Tasks are slow and prevent the browser from producing a Frame
  • Competing Tasks - the browser's Task Queue is flooded with Tasks, and runs other tasks between your Task and producing a Frame

When does Paint occur?

The browser event loop is responsible for interleaving your JavaScript tasks (like React DOM updates) and rendering Frames on the browser's Main Thread.

This means that any DOM updates in your JavaScript must complete before the browser can present those updates visually to your users.

For example, let's say you have some React rendering that drives the presentation of your UI to your users. Any changes made by React via JavaScript won't be visually reflected until after Paint runs.

A diagram showing React running, and the frame being produced afterwards

Detecting when Paint occurs

The most flexible and cross-browser compatible way to detect when Frame paint occurs is through the following snippet:

/**
 * Runs `callback` shortly after the next browser Frame is produced.
 */
function runAfterFramePaint(callback) {
    // Queue a "before Render Steps" callback via requestAnimationFrame.
    requestAnimationFrame(() => {
        const messageChannel = new MessageChannel();

        // Setup the callback to run in a Task
        messageChannel.port1.onmessage = callback;

        // Queue the Task on the Task Queue
        messageChannel.port2.postMessage(undefined);
    });
}

You will want to invoke runAfterFramePaint in the same Task as the one that's performing your DOM updates.

For example:

function updateDom() {
    const node = document.getElementById('some-node');

    node.innerText = 'Some Text';

    // Other DOM Updates...
}

function main() {
    performance.mark('App_Start');

    // Updates DOM in this Task
    updateDom();

    // Queues a requestAnimationFrame relative to this executing Task
    runAfterFramePaint(() => {
        performance.mark('App_FrameProduced');
        
        const measure = performance.measure('FrameTime', 'App_Start', 'App_FrameProduced');

        console.log(`The Frame was produced after ${measure.duration}ms`);
    });
}

main();

If we visualized this snippet on the Main Thread, it would look like this:

A diagram showing the above snippet as Tasks on the Main Thread, with Frame Time measured

Note: requestAnimationFrame is often abbreviated as rAF and verbally pronounced as "raff".

How does this work?

At first glance, the APIs invoked here look quite peculiar given our objective is to measure Frame time:

  • We aren't animating anything -- why are we calling requestAnimationFrame!?
  • We aren't messaging anything -- why are we posting to a new MessageChannel!?

However, understanding how the browser represents these two APIs will clear things up!

requestAnimationFrame

requestAnimationFrame allows web developers to run a callback right before the browser completes next the Render Steps Task. This differs from ordinary Tasks, which are interleaved by the Event Loop and drawn from the Task Queue.

Observe the following diagram, and when requestAnimationFrame callbacks run:

A diagram showing requestAnimationFrame running

For more details on requestAnimationFrame, read my in-depth tip on the API.

MessageChannel

MessageChannel is usually used for posting messages between threads or processes within the browser, but we aren't utilizing it for its intended purpose here!

I've extracted the relevant part of runAfterFramePaint here for reference:

const messageChannel = new MessageChannel();

// Setup the callback to run in a Task
messageChannel.port1.onmessage = callback;

// Queue the Task on the Task Queue
messageChannel.port2.postMessage(undefined);

MessageChannel is being used here as a generic mechanism to Post a Task to the Task Queue.

Furthermore, Tasks posted to the Task Queue via MessageChannel are prioritized above most other Tasks.

As a result, what we are telling the browser to do here is: Run callback as a Task, with high priority.

Putting it together

When we call postMessage inside of requestAnimationFrame, the the browser is instructed to queue a high priority task on the Task Queue.

A diagram showing postMessage being invoked within a requestAnimationFrame callback

A diagram showing postMessage queueing a high priority task on the Task Queue

Once the Task is queued, the browser continues to produce a Frame by completing the Render Steps (which always run after requestAnimationFrame callbacks complete).

A diagram showing a Frame being produced after requestAnimationFrame, with message task queued

Once the Render Steps complete, the Event Loop resumes pulling from the Task Queue, and selects the next Task -- in this case, a high-priority onmessage Task.

A diagram showing onmessage running after the Render Steps

We now have a handle into a Task that runs shortly after Frame paint!

How accurate is this?

This method is not 100% accurate, but it's as close as web developers can get with currently available browser APIs.

A few points of note:

  • requestAnimationFrame won't run if the page is hidden (i.e. in a background tab), so you should exclude hidden tabs from your measurements.
  • MessageChannel onmessage callbacks are high priority, but input events like click and keypress take precedence, and so it won't always run immediately after a Frame is produced.

Example App

I've put together the example above in a demo page for you to collect a trace of yourself!

In this example, I capture two measurements:

  • JavaScript_Constructing_DOM_Time - represents the time from click to completing all DOM edits
  • Frame_Time - represents the time from click to presenting the DOM edits to the user's screen as a Frame

A Chromium Profiler trace of the Frame Time being measured.

One can observe that the Frame_Time is longer than the JavaScript_Constructing_DOM_Time, because it includes the Render Steps Tasks:

A Chromium Profiler trace showcasing the extra time between DOM Updates and Frame generation.

Conclusion

With this knowledge you can ensure your performance marks and measures are user-centric, and capture when your key pixels are visualized on your user's screen.

Consider the following tips next:

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