How to get element bounds without forcing a reflow

toruskit

toruskit

Posted on June 28, 2021

How to get element bounds without forcing a reflow

Getting the element bounds (size and position) seems like a trivial task. Just use getBoundingClientRect() in loop on bunch of elements and you're done. The truth is, that works pretty well, except the one thing - a performance. You're likely to force a browser reflow. And when you have a huge amount of elements, the performance hurt can be significant.

In this post, I'm gonna show you a little bit unusual approach to getting element bounds with the use of IntersectionObserver

What is a browser reflow

Long story short. There are a lot of resources about the reflows, so I will take it fast.

The reflow is a process when the browser needs to re-calculate the position and dimensions of the elements on the page. The reflow always occurs when the page is loaded and the browser needs to traverse the DOM to get all elements. This is very expensive (in the meaning of performance) and can make longer rendering, junky scrolling or sluggish animations.

Forcing a browser reflow can be done just by changing the width of the element by as little as 1px. Yes, it's so small amount, but the browser needs to check the new position of the element and also how it affected other elements on the page. So it's better to use a transform property for that. But this is out of scope of this article.

The old ways of getting the element dimensions

Get element offsetTop/offsetLeft value

This is the very old method of getting the element position using offsetTop or offsetLeft. Unfortunately, there is one (serious) detail to keep in mind - it returns the position relative to the parent element and not the absolute position relative to the page. Even there is a solution using offset.js script, it still forces reflow.

Call getBoundingClientRect()

This one is more precise and easier to use. It returns the element size and position relative to the viewport. You'll get left, top, right, bottom, x, y, width, and height values of selected element. It's relatively fast when you have a small number of elements. But it's getting to be slower and forcing a reflow when the number of elements starts to rise dramatically, or when calling multiple time.

Use IntersectionObserver to get element bounds

This is the relatively unknown approach of getting the dimension and position of the element, because of the IntersectionObserver is primarily used to calculate the visibility of the element in the viewport.

What is IntersectionObserver

As it's mentioned in the MDN docs:

The Intersection Observer API provides a way to asynchronously observe changes in the intersection of a target element with an ancestor element or with a top-level document's viewport.

The magic keyword - asynchronously is why the performance will thank you. All the calculations are done "off the main thread" so the browser has much time to do the optimizations.

But how to get element bounds with this, and what to do if the element is not even visible in the viewport?

In fact, you don't need to care. IntersectionObserver API has a boundingClientRect property that calculates the element dimension independently on its visibility.

boundingClientRect to the rescue

The boundingClientRect is the IntersectionObserver API interface that returns a read-only value of the rectangle describing the smallest rectangle that contains the entire target element. It's like the getBoundingClientRect() but without forcing a reflow. You'll get left, top, right, bottom, x, y, width, and height.

This property is accessible inside the IntersectionObserver constructor via entry.boundingClientRect.

How to use it

Finally, let's take a look at how to use this all to get the element dimensions without making the browser hate us.

The full script looks like this:

// new `IntersectionObserver` constructor
const observer = new IntersectionObserver((entries) => {
  // Loop through all `entries` returned by the observer
  for (const entry of entries) {
    // The `entry.boundingClientRect` is where all the dimensions are stored
    const bounds = entry.boundingClientRect;
    // Log the `bounds` for every element
    console.log(bounds);

    // Then do whatever with `bounds`
  }

  // Disconnect the observer to stop from running in the background
  observer.disconnect();
});

// Select all the `.element` elements
const elements = document.querySelectorAll(".element");

// Loop through all elements
for (const element of elements) {
  // Run the `observe` function of the `IntersectionObserver` on the element
  observer.observe(element);
}
Enter fullscreen mode Exit fullscreen mode

The entry.boundingClientRect is where the magic happens. This property stores all the element dimensions and positions.

Now let's take a closer look on each definition.

The first step is to create a new IntersectionObserver constructor that takes a list of elements as an argument and applies its calculations. Note to mention - you can pass custom options to the observer, but we're going to keep defaults one, as we don't need to track visibility.

const observer = new IntersectionObserver((entries) => {

});
Enter fullscreen mode Exit fullscreen mode

Inside this IntersectionObserver, we need to loop through all entries that will be passed later in the loop. This is the place where you get elements bounds for further use{.bg-green .bg-opacity-20}. We'll use bounds constant to store the entry.boundingClientRect values so when you need to get x or height value of the element, just use bounds.x or bounds.height.

for (const entry of entries) {
  const bounds = entry.boundingClientRect;

  // Use `bounds` like you need
  // Example: `bounds.height` will return the element `height` value in px

}
Enter fullscreen mode Exit fullscreen mode

When the observing is done, it's good to disconnect the observer as we don't need it anymore.

observer.disconnect();
Enter fullscreen mode Exit fullscreen mode

Then we need to select all the elements on which we need to determine their bounds. They'll be stored in the .elements constant.

const elements = document.querySelectorAll(".element");
Enter fullscreen mode Exit fullscreen mode

And finally, loop through all of them and run the observer on them. This may look like a synchronous call, but in fact, the IntersectionObserver is not triggered immediately when the observer.observe(element); is called. Instead, it waits and then takes a bunch of elements and runs the calculations asynchronously.

for (const element of document.querySelectorAll(".element")) {
  observer.observe(element);
}
Enter fullscreen mode Exit fullscreen mode

Performance: getBoundingClientRect() vs IntersectionObserver

To get an idea of how fast and performant the IntersectionObserver is, I've made a quick comparison with the old getBoundingClientRect() method.

I've generated 5000 squared <div> elements and give them a .element class with basic stylings such as size and background color. There are no other elements that could affect performance.

Get element bounds without reflow - Rendered page with 5000 elements

Now let's compare the getBoundingClientRect() vs IntersectionObserver.

Simple test

These are the scripts to evaluate the performance of the both methods:

const elements = document.querySelectorAll(".element");

// `getBoundingClientRect()`

for (const element of elements) {
  const bounds = element.getBoundingClientRect();
}

// `IntersectionObserver`

const observer = new IntersectionObserver((entries) => {
  for (const entry of entries) {
    const bounds = entry.boundingClientRect;
  }

  observer.disconnect();
});

for (const element of elements) {
  observer.observe(element);
}
Enter fullscreen mode Exit fullscreen mode

When using getBoundingClientRect() results without any further manipulation, everything runs pretty fast. Check the live demo to see how it performs in your browser.

When using IntersectionObserver in this live demo everything is fast, too. It seems there is no big difference until you check the Performance tab in Google Chrome tools. When running getBoundingClientRect(), the browser is forced to do a reflow and it takes longer to evaluate the script.

On the other hand, using IntersectionObserver makes no reflows, and the script runs as fast as possible. Take to count that the page has 5000 elements, so parsing and recalculating styles take more time in both cases.

getBoundingClientRect vs IntersectionObserver comparison

Let's get complicated

Even that the first method is not as fast as the second, the performance hit is not so obvious. But what if you need to display the element's dimensions somewhere.

This example shows what happens when we want to display the bounds on each element as text content using CSS ::after pseudo-element.

But first, let's edit the code a little bit and add a line that sets a data-bounds attribute on the element.

const elements = document.querySelectorAll(".element");

// `getBoundingClientRect()`

for (const element of elements) {
  const bounds = element.getBoundingClientRect();
}

// `IntersectionObserver`

const observer = new IntersectionObserver((entries) => {
  for (const entry of entries) {
    const bounds = entry.boundingClientRect;
  }

  observer.disconnect();
});

for (const element of elements) {
  observer.observe(element);
}
Enter fullscreen mode Exit fullscreen mode

The results are shocking. While the IntersectionObserver method looks like there's no difference, the getBoundingClientRect() method got mad. It takes 1.14s to evaluate the script and makes a huge amount of reflows.

getBoundingClientRect vs IntersectionObserver comparison with dataset

OK, someone can argue that this is because the IntersectionObserver runs in asynchronous mode. It's true, so let's make the getBoundingClientRect() asynchronous with this script:

const promises = [];

async function loop() {
  for (const element of elements) {
    let bounds = await element.getBoundingClientRect();
    promises.push(bounds);
  }

  Promise.all(promises).then((results) => {
    for (const [i, element] of Object.entries(elements)) {
      let result = results[Number(i)];
      element.dataset.bounds = `x: ${result.x} y:${result.y} width: ${result.width} height: ${result.height}`;
    }
  });
}

loop();
Enter fullscreen mode Exit fullscreen mode

The results are much better compared with synchronous method. There are magically no reflows, but the script evaluation time is still longer then IntersectionObserver

getBoundingClientRect vs IntersectionObserver with asynchronous call

Wrapping it up

As you can see, the IntersectionObserver can be used not just to check the element visibility, but also to calculate its dimensions and position. Compared to getBoundingClientRect() it's faster and doesn't produce any reflows. Even when the getBoundingClientRect() is used in asynchronous function, it's still slower.

In the Torus Kit, we're using this approach to get element bounds as fast as possible without unnecessary reflows.

💖 💪 🙅 🚩
toruskit
toruskit

Posted on June 28, 2021

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related