How to get element bounds without forcing a reflow
Tags: javascript, performance
Published: 2021-06-28 09:55:13

Getting the element bounds (size and position) seems like a trivial task. Just use [getBoundingClientRect()](https://developer.mozilla.org/en-US/docs/Web/API/Element/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](https://developers.google.com/speed/docs/insights/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](https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API)

## What is a browser reflow

Long story short. There are a lot of [resources](https://developers.google.com/speed/docs/insights/browser-reflow) about the [reflows](https://sites.google.com/site/getsnippet/javascript/dom/repaints-and-reflows-manipulating-the-dom-responsibly), 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](http://jankfree.org/) 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`](https://csstriggers.com/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](https://gist.github.com/alexcambose/830e33a8f360e0b4a477e6e8f64fcd5a#file-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`](https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API) 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:

```js
// 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);
}
```

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.

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

});
```

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`.

```js
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

}
```

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

```js
observer.disconnect();
```

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

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

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.

```js
for (const element of document.querySelectorAll(".element")) {
  observer.observe(element);
}
```

## 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](https://toruskit.com/blog/how-to-get-element-bounds-without-reflow/get-element-bounds-without-reflow-generated-elements.png)

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

### Simple test

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

```js
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);
}
```

When using `getBoundingClientRect()` results without any further manipulation, everything runs pretty fast. Check the [live demo](./get-bounding-client-rect.html) to see how it performs in your browser.

When using `IntersectionObserver` in this [live demo](./intersection-observer.html) 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](https://toruskit.com/blog/how-to-get-element-bounds-without-reflow/get-element-bounds-without-reflow-comparison.png)

### 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.

```js
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);
}
```

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.

<img class="img-fluid py-4" src="https://toruskit.com/blog/how-to-get-element-bounds-without-reflow/get-element-bounds-without-reflow-dataset-comparison.png" alt="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:

```js
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();
```

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

<img class="img-fluid py-4" src="https://toruskit.com/blog/how-to-get-element-bounds-without-reflow/get-element-bounds-without-reflow-async-comparison.png" alt="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](https://toruskit.com), we're using this approach to get element bounds as fast as possible without unnecessary reflows.