Skip to main content
menu

BMT Systems

  WEB & JAVASCRIPT  

Mobile Navigation Controls

Preventing Forced Reflows with Strategic JavaScript: II

Thomas M. Brodhead

This is part 2 of a two-part article on forced reflows.

Code

Better than pseudo-code is actual code that creates forced reflows and provides coding solutions for direct examination. Below is the code we’ll be using for the remainder of this article. You may run it directly from this site at code-examples/forced-reflow-solutions.html. However, it would be best to save it as forced-reflow-solutions.html and run it on a localhost machine to test modifications to the code outside what’s discussed here. Regardless, in a modern Chrome browser (version 61 as of this writing) in incognito mode, and with the DevTools open, run this code:

brightness_high content_paste
<!DOCTYPE html>
<html>
<head>
    <meta charset=utf-8>
    <title>Forced Reflow Solutions</title>
    <style>
body {
    font: 16px 'Arial', sans-serif;
    overflow-y: scroll;
}
h1 {
    text-align: center;
    font: 20px 'Arial', sans-serif;
}
h2 {
    text-align: center;
    font: 16px 'Arial', sans-serif;
}
.buttons, .list div {
    display: table;
    margin: 0 auto;
    text-align: center;
}
.buttons {
    margin: 1em auto;
}
.ex-text {
    display: none;
}
.explanation div {
    margin: 1em auto;
    text-align: center;
}
.explanation .display-block {
    display: block;
}
.list div {
    background-color: lightBlue;
}
    </style>
</head>
<body>
    <h1>Run This in Chrome Incognito Mode with DevTools Open</h1>
    <h2>Generate DIVs with random text, double width of each DIV relative to text width</h2>
    <div class=buttons>
        <button value=1>Method 1</button>
        <button value=2>Method 2</button>
        <button value=3>Method 3</button>
        <button value=4>Method 4</button>
        <button value=5>Method 5</button>
    </div>
    <div class=explanation>
        <div class=ex-text id=1>Method 1: Resize DIVs in same loop where they're created.</div>
        <div class=ex-text id=2>Method 2: Resize DIVs in separate loop, but get clientWidth of DIV and set width of DIV in same statement.</div>
        <div class=ex-text id=3>Method 3: Resize DIVs in separate loops: one to collect clientWidths, the other to set widths relative to them.</div>
        <div class=ex-text id=4>Method 4: Same as method 3, but wrapping each loop in a requestAnimationFrame.</div>
        <div class=ex-text id=5>Method 5: Same as method 4, except using FastDom to separate measurements from mutations.</div>
    </div>
    <div class=list></div>
<!-- See: https://github.com/wilsonpage/fastdom -->
    <script src="https://cdnjs.cloudflare.com/ajax/libs/fastdom/1.0.6/fastdom.min.js"></script>
    <script>
(function () {

    'use strict';

    function makeList(method) {

/////////////////////
// Declare variables:
/////////////////////

        var div;
        var divArr;
        var divWidthArr;
        var fragment;
        var listDiv;
        var randomText;

////////////////////////
// Initialize variables:
////////////////////////

        divArr = [];
        divWidthArr = [];
        listDiv = document.querySelector('.list');

/////////////
// Prep Work:
/////////////

// Clear out previous console display:
        console.clear();

// Hide previous explanation:
        Array.prototype.forEach.call(document.querySelectorAll('.ex-text'), function (element) {
            element.classList.remove('display-block');
        });

// Display current explanation:
        document.getElementById(method).classList.add('display-block');

// Remove any previous contents of .list DIV:
        while (listDiv.firstChild) {
            listDiv.removeChild(listDiv.firstChild);
        }

////////////////
// Main Routine:
////////////////

// Create documentFragment for methods 2-5:
        if (method !== 1) {
            fragment = document.createDocumentFragment();
        }

// Create DIVs with random text and insert in list DIV:
        for (var i = 0; i < 1000; i++) {

// Create DIV:
            div = document.createElement('DIV');
// Create random text:
            randomText = Math.random().toString(36).substr(2);
// Put random text in DIV:
            div.innerHTML = randomText;
// Save reference to DIV in divArr
            divArr.push(div);

// Next, double the size of each DIV:

///////////////////////////////////////////
// METHOD 1: forced reflows.
// Measure (clientWidth) and mutate (width)
// in same statement; also performed once
// per loop iteration:
///////////////////////////////////////////
            if (method === 1) {
                listDiv.appendChild(div);
                div.style.width = (div.clientWidth * 2) + 'px';
            } else {
                fragment.appendChild(div);
            }

        }

// For all methods except #1, transfer the fragment contents to the DOM:
        if (method !== 1) {
            listDiv.appendChild(fragment);
        }

/////////////////////////////////////////
// METHOD 2: forced reflows.
// SLOW.
// Measure and mutate in dedicated loop,
// but still performed in same statement:
/////////////////////////////////////////
        if (method === 2) {
            divArr.forEach(function (div) {
                div.style.width = (div.clientWidth * 2) + 'px';
            });
        }


///////////////////////////////////////
// METHOD 3: occasional forced reflows.
// FASTER.
// Measure and mutate operations placed
// in separate loops:
///////////////////////////////////////
        if (method === 3) {
// MEASURE: Fill the divWidthArr[] with each DIV's clientWidth:
            divArr.forEach(function (div) {
                divWidthArr.push(div.clientWidth);
            });
// MUTATE: Set each DIV's width to twice its clientWidth:
            divArr.forEach(function (div, index) {
                div.style.width = (divWidthArr[index] * 2) + 'px';
            });
        }


////////////////////////////////////////
// METHOD 4: no forced reflows.
// Measure and mutate operations placed
// in separate loops, both in 
// callback to requestAnimationFrame():
////////////////////////////////////////
        if (method === 4) {
            window.requestAnimationFrame(function () {
// MEASURE: Fill the divWidthArr[] with each DIV's clientWidth:
                divArr.forEach(function (div) {
                    divWidthArr.push(div.clientWidth);
                });
// MUTATE: Set each DIV's width to twice its clientWidth:
                divArr.forEach(function (div, index) {
                    div.style.width = (divWidthArr[index] * 2) + 'px';
                });
            });
        }


///////////////////////////////
// METHOD 5: no forced reflows.
// Distinguish measure operations from mutation operations,
// nesting fastdom.mutate within fastdom.measure
///////////////////////////////
        if (method === 5) {
// MEASURE:
            window.fastdom.measure(function () {
                divArr.forEach(function (div) {
                    divWidthArr.push(div.clientWidth);
                });
// MUTATE:
                window.fastdom.mutate(function () {
                    divArr.forEach(function (div, index) {
                        div.style.width = (divWidthArr[index] * 2) + 'px';
                    });
                });
            });
        }

    }

// Add click listeners to buttons; call makeList() with button value as argument:
    Array.prototype.forEach.call(document.querySelectorAll('button'), function (button) {
        button.addEventListener('click', function () {
// Wrap makeList call in rAF:
            window.requestAnimationFrame(function () {
                makeList(parseInt(button.value));
            });
        });
    });

}());

    </script>
</body>
</html>

Open the Code in Chrome

If you open forced-reflow-solutions.html in Chrome incognito mode with the DevTools open to the Console display, it should look like this:

forced-reflow-solutions.html and DevTools in Chrome Incognito Mode

Update: In the Console display, besides Filter in the menu at the top, click the All Levels drop-down list and ensure that Verbose, Info, Warnings, and Errors are all selected:

Verbose, Info, Warnings, and Errors must be selected in the Console display

Press a button and the JavaScript will generate a list of random strings, each in a separate <div> element, and each surrounded by a colored border that is twice the width of the text the <div> element contains. The list is headed by a short description of what distinguishes the method employed from the others.

To preview the application and the messages that appear in the DevTools Console display, press each button once and see the results. We’ll examine each routine and analyze its performance in the sections that follow.

Surveying the Methods

Press in the application. After the list is generated, there should be two errors reported in the console:

[Violation] 'requestAnimationFrame' handler took 470ms
[Violation] Forced reflow while executing JavaScript took 434ms

The timing reported should be different, but the error messages should be the same: a requestAnimationFrame handler error and a forced reflow error.

Now press in the application. After the list is generated, there should be two errors reported in the console:

[Violation] 'requestAnimationFrame' handler took 738ms
[Violation] Forced reflow while executing JavaScript took 703ms

The timing should be longer than Method 1. Now press  :

[Violation] 'requestAnimationFrame' handler took 59ms
[Violation] Forced reflow while executing JavaScript took 41ms

The execution time here is much shorter, which is better. Now press  :

...nothing! DevTools reports no errors, and the display change was virtually instantaneous. Now press  :

...nothing! Again, DevTools reports no errors, and the display change was virtually instantaneous.

We’ll now analyze each of these methods individually, seek the cause of the performance problems, and see the solutions that you can adopt in your own routines. We’ll record the performance of each routine using Chrome’s DevTools.

Running and Recording Method 1

Click on the Performance tab and the DevTools display should change to this:

Performance tab in Chrome DevTools

Next:

  • Start recording by clicking the black record button in the DevTools display
  • Press in the application
  • Press the blue Stop button in the DevTools display as soon as the text list has changed in the browser

The DevTools display will look like this:

Graphical performance display in Chrome DevTools

The red line in the FPS (Frames Per Second) display means trouble. Click any of the thin blue and pink slices at the bottom of the Main section, and magnify the display by pressing W and A or D. The keyboard controls for adjusting the magnification of the Main section are:

Wto expand the display (“widen” the area)
Sto shrink the display (“shrink” the area)
Ato move the display area left
Dto move the display area right

Update: As of Chrome 66, released April 2018, you may move, expand, and contract the targeted display area simply by selecting the grey, vertical handle bars on the top left and right of the frame “window,” which is much easier to perform than the QWERTY method described above.

Once the magnification is sufficient, the formerly pinkish slices at the bottom of Main will be distinct purple rectangles, each with a red triangle in the upper righthand corner. Purple is the color for layout in the display, and a red triangle is the flag for a rendering issue. If you hover your cursor over the purple area, the location of the offending code will be identified:

Magnified performance display graphic in Chrome DevTools

It’s line 139 in forced-synchronous-layout.html, which contains the mixture of measurement (.clientWidth) and mutation (.width) in the same statement:

brightness_high content_paste
131
132
133
134
135
136
137
138
139
140
141
152
///////////////////////////////////////////
// METHOD 1: forced reflows.
// Measure (clientWidth) and mutate (width)
// in same statement; also performed once
// per loop iteration:
///////////////////////////////////////////
if (method === 1) {
    listDiv.appendChild(div);
    div.style.width = (div.clientWidth * 2) + 'px';
} else {
    fragment.appendChild(div);
}

This is precisely the kind of coding that would be considered efficient or perhaps even elegant in other languages and contexts. Here, it is not performant because of the DOM-thrashing that it causes: the repetitions of measurement and mutation in each iteration of the forEach loop produce the 1,000 forced reflows, corresponding to the 1,000 purple rectangles with red warning triangle flags in the performance display. (This is the kind of identification procedure that you’ll use to identify the offending code in your own routines when a forced reflow is detected by Chrome.)

Now let’s repeat this procedure, but this time let’s see if Method 2 is any better.

Running and Recording Method 2

Press the Clear button in DevTools to erase the Performance display, then follow the same recording procedure as before, this time selecting  :

  • Start recording by clicking the black record button in the DevTools display
  • Press in the application
  • Press the blue Stop button in the DevTools display as soon as the text list has changed in the browser

The results this time are no better:

Performance using Method 2

We have 1,000 individual thrashed layouts again. Here, it’s line 159 in forced-synchronous-layout.html, which again contains the mixture of measurement and mutation on the same line, this time moved to a separate loop outside the <div> element creation loop:

brightness_high content_paste
151
152
153
154
155
156
157
158
159
160
161
/////////////////////////////////////////
// METHOD 2: forced reflows.
// SLOW.
// Measure and mutate in dedicated loop,
// but still performed in same statement:
/////////////////////////////////////////
if (method === 2) {
    divArr.forEach(function (div) {
        div.style.width = (div.clientWidth * 2) + 'px';
    });
}

Simply moving that code outside the <div> creation loop was not sufficient to cure the forced reflow; it’s the mixing of measurement and mutation that’s the culprit.

Running and Recording Method 3

Now let’s see how well the separation of measurement and mutation performs. Press the Clear button in DevTools to erase the Performance display, then follow the same recording procedure as before, this time selecting  . The results this time are better:

Performance using Method 3, separating measurement from mutation

The execution time is much shorter, and the 1,000 individual purple layout rectangles with red triangles have been replaced with just 2. Click on the purple rectangle at the bottom and use the resize controls (W, S, A, D) to enlarge it until it looks more or less like this:

Magnification of Method 3 result

Hovering over the purple rectangle reveals that line 173 in the code is problem source:

brightness_high content_paste
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
///////////////////////////////////////
// METHOD 3: occasional forced reflows.
// FASTER.
// Measure and mutate operations placed
// in separate loops:
///////////////////////////////////////
if (method === 3) {
// MEASURE: Fill the divWidthArr[] with each DIV's clientWidth:
    divArr.forEach(function (div) {
        divWidthArr.push(div.clientWidth);
    });
// MUTATE: Set each DIV's width to twice its clientWidth:
    divArr.forEach(function (div, index) {
        div.style.width = (divWidthArr[index] * 2) + 'px';
    });
}

Getting .clientWidth produces a single forced reflow, not 1,000 forced reflows. This is because the measurement is likely performed in between animation frames.

Running and Recording Method 4

Let’s now see the results when we ensure this routine occurs at the beginning of an animation frame. Press the Clear button in DevTools to erase the Performance display, then follow the same recording procedure as before, this time selecting  . The results this time are much better:

Method 4 result

The purple layout rectangles lack red warning triangles, so Method 4 eliminates the forced layouts. Hovering over the purple rectangles again reveals the corresponding section of JavaScript, and this time the relevant code is line 196:

brightness_high content_paste
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
////////////////////////////////////////
// METHOD 4: no forced reflows.
// Measure and mutate operations placed
// in separate loops, both in 
// callback to requestAnimationFrame():
////////////////////////////////////////
if (method === 4) {
    window.requestAnimationFrame(function () {
// MEASURE: Fill the divWidthArr[] with each DIV's clientWidth:
        divArr.forEach(function (div) {
            divWidthArr.push(div.clientWidth);
        });
// MUTATE: Set each DIV's width to twice its clientWidth:
        divArr.forEach(function (div, index) {
            div.style.width = (divWidthArr[index] * 2) + 'px';
        });
    });
}

We’ve wrapped the forEach loops of Method 3 within a requestAnimationFrame request, ensuring that the loops begin at the start of an animation frame. This is the key to preventing forced layouts. But we can take this one step further with Method 5, which uses fastdom.

Running and Recording Method 5

Let’s perform the same procedure using fastdom, the JavaScript function that provides precise control over the queueing of measurements and mutations. (Fastdom.min.js is called from a CDN in a <script> element located above the site script in the HTML.) Press the Clear button in DevTools to erase the Performance display, then follow the same recording procedure as before, this time selecting  . The results this time are the same as Method 4:

Method 5 result

The difference between Method 4 and Method 5 is that the latter uses the third-party fastdom JavaScript function. The corresponding code is:

brightness_high content_paste
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
///////////////////////////////
// METHOD 5: no forced reflows.
// Measure operations placed within fastdom measure function;
// mutate operations placed within nested fastdom mutate function.
///////////////////////////////
if (method === 5) {
// MEASURE:
    window.fastdom.measure(function () {
        divArr.forEach(function (div) {
            divWidthArr.push(div.clientWidth);
        });
// MUTATE:
        window.fastdom.mutate(function () {
            divArr.forEach(function (div, index) {
                div.style.width = (divWidthArr[index] * 2) + 'px';
            });
        });
    });
}

Here, the mutation block is nested inside the measurement block, ensuring that the mutations are cued after the measurements.

Review

We’ve covered how to use the Chrome DevTools to identify the source of forced reflows, and how to restructure JavaScript to prevent them. In general:

  • Identify and separate measurement and mutation functions
  • Place measurements before mutations in your code
  • Use requestAnimationFrame() or fastdom to ensure that these procedures occur at the beginnings of animation frames, thus preventing DOM thrashing, a.k.a. forced reflows.

Further Reading

Aside