Have you ever come across (or event built!) a cool, modern, sleek, animation-heavy website... and opened it on mobile only to see that it runs like Alan Wake 2 on a 2003 Dell Inspiron?
What is layout thrashing?
Layout thrashing, a.k.a. forced synchronous reflow, occurs when the browser is continuously forced to drop its optimized queueing of layout and style recalculations and do it ASAP because a script running needs to know the latest layout measurements.
Because layout re-calculation and repainting are resource-heavy operations, if the browser is forced to do them too often and not "at the right time", this leads to very jittery laggy and possibly unresponsive page.
What causes it?
As a user interacts with the page and layout changes or animations play, the browser has to keep re-rendering the latest version of a page. It does in roughly two distinct steps:
- 🌊 Reflow: re-calculate the position, size, etc of the elements on the page
- 🎨 Repaint: re-draw elements whose appearance has changed
(I refer you to this great article for more info on those.)
How often reflow and repaint happen depends on the screen refresh rate and on the what the JavaScript is doing.
Ideally this happens at most once every display refresh "tick", i.e. 60, 75 or more times a second (the screen refresh rate). This is because, as mentioned, reflow and repaint are quite resource expensive and lead to laggy performance when done more often than that.
However, unless the scripts on the page have been specifically optimized for that, chances are good that reflows and/or repaints are happening multiple times in each animation frame.
Normally browsers are pretty good at optimizing the reflows/repaints and defer them until just before the next display refresh tick. The browser is also clever about caching layout information and knows whether it actually needs to re-calculate it or not by keeping track of what has changed since the last reflow, i.e. whether the style/layout has been "invalidated". But if a script reads or "measures" a layout property, such as the height of an element or its offset on the page, after the style has been invalidated, then the browser is forced to re-calculate now in order to give the correct measurement.
So for a forced synchronous reflow to happen, then 2 things need to follow in order:
- A script modifies the style in a way that invalidates the current style, by for example adding a CSS class or directly modifying the inline style. From now on I will refer to this as a "mutation".
- Then it asks for measurement that depends on the latest style such as the height of an element. From now on I will refer to this as a "measurement".
How to avoid it?
The ideal order of operations that avoids forced reflows is:
- Start of frame: Browser runs is scheduled (optimized) recalculation and repaint.
- JavaScript can freely read or "measure" any layout values such as
offsetHeight
,clientHeight
,getBoundingClientRect
,getComputedStyle
,event.offsetX
, etc. See here for a comprehensive list of "measurement" operations that if not done on a valid layout will force synchronous reflow. - ... more calculations
- End of frame:
requestAnimationFrame
callbacks fire; callbacks can now "mutate" the style by adding/removing CSS classes, adding, removing, moving elements, etc. - Repeat.
To achieve this, the general principle is to
- Batch and defer all style mutations and to just before the end of the frame, which is the scheduled time for reflows.
- Ensure that any measurements are done after a scheduled reflow has happened and before the style has been invalidated.
Step 1 is easy: any code that needs to modify the style should run in a callback to requestAnimationFrame
. You can do this yourself or use something like fastdom.
// Step 1: batch all mutations to the end of the frame
requestAnimationFrame(() => {
// ... modify the DOM, e.g.
element.classList.add("cls");
element.append(newChild);
});
Step 2 could be a bit tricky. If all the code running on the webpage is yours and all your style modifications are batched and deferred as mentioned in step 1, then you can safely just do all measurements at any time except inside the mutation callbacks (because at that time the style has been invalidated and doing measurements will force a reflow).
// Step 2: Simpler naive approach
// Just do your measurements synchronously,
// assuming that all mutations will be deferred
// till the end of the frame
// Fingers crossed the style is not invalidated here
const elHeight = element.offsetHeight;
However, in most cases not all of the code on the page is yours and other code isn't so considerate so as to defer its mutations to the end of the frame, and so when you do a measurement and it happens to run in an already invalidated style it will force a reflow. fastdom takes a simple approach by also deferring all measurements, in addition to mutations, to the end of the frame, and runs all measurements first, then all mutations. This ensures that a reflow forced by your code can happen at most once in a frame: during the first measurement task that runs in the requestAnimationFrame
callback. Then at the end of the frame of course there will be a scheduled reflow, because the style would have been invalidated by the mutation tasks.
For this reason I chose to not use fastdom in my latest project but wrote my own implementation in which I:
- Batch and defer mutations till the end of the frame as before.
- Batch and defer measurements till just after the start of the next frame when the style is almost certainly still valid, having just been re-calculated.
Now forced reflows by my own code are eliminated (even if other code on the page has mutated the layout during the frame).
Step 2 is achieved by first waiting for the requestAnimationFrame
callbacks to fire, which happens just before a paint, and then scheduling a high priority task using either the Scheduler.postTask method which is only available in recent browsers, or the fallback legacy MessageChannel. These two methods schedule a new task (not a microtask, but a macrotask) that runs with higher priority than say setTimeout
. I refer you to this excellent post on the event loop and tasks.
This works because tasks scheduled from inside requestAnimationFrame
callbacks will be deferred till after any scheduled reflow/repaint that will follow said callbacks. Again note that I'm not talking about microtasks such as Promise resolve callbacks: these will run in the same frame, before the repaint.
// Step 2: A more robust approach
// Defer your measurements till after the next scheduled reflow
const scheduleHighPriorityTask = (task) => {
if (typeof scheduler !== "undefined") {
scheduler.postTask(task, {
priority: "user-blocking",
});
} else {
// Fallback to MessageChannel
const channel = new MessageChannel();
channel.port1.onmessage = () => {
channel.port1.close();
task();
};
channel.port2.postMessage("");
}
};
requestAnimationFrame(() => {
// end of frame here, mutations will run/have just run
scheduleHighPriorityTask(() => {
// start of next frame: scheduled reflow has just happened
const elHeight = element.offsetHeight;
});
});
How to test for it?
The browser's built-in Performance monitoring tool can help you spot forced reflows.
- Open the Performance tab and start recording
- Do something on the page that you suspect causes reflows
- Stop recording and examine the timeline. Force reflows will be little purple boxes that say "Recalculate style" that appear below the top row of events (ones on the top row will be the scheduled ones that run at the optimal time). When you click on those boxes it will also tell you which line of which file caused that:
Here is the simple pen I wrote that you can use to play with it (screenshot taken from there when running the unoptimized version).
TL;DR;
Layout thrashing is when the browser is forced to perform a synchronous, out-of-schedule, re-calculation of the style because JavaScript has previously modified the layout or style and invalidated the browser's cached measurements, and now JavaScript wants to read the latest value of a property from the layout.
To avoid it:
- Batch and defer all code that modified the DOM till just before the end of the frame using
requestAnimationFrame
. - Batch and defer all your measurements till just after the start of the next frame using
requestAnimationFrame
and then a high priority task scheduler likeScheduler
orMessageChannel
.
Thanks for reading!
Thank you for reading, like if you like, follow me you X. And be happy and kind 🌞
Top comments (1)
I learned some stuff in this post. Great read.