Don’t attach tooltips to document.body
Tags: javascript, performance
Published: 2021-09-06 20:28:50

---
title: Don’t attach tooltips to document.body
published: true
date: 2021-03-10 00:00:00 UTC
tags: javascript, performance
canonical_url: https://atfzl.com/don-t-attach-tooltips-to-document-body
---

## TL;DR

Instead of attaching tooltips directly to `document.body`, attach them to a predefined div in `document.body`.

**BAD**

```
<body>
    <!-- temporary div, vanishes when tooltips vanishes -->
    <div>my tooltip</div>
<body>
```

**GOOD**

```
<body>
    <!-- this div stays forever, just for attaching tooltips -->
    <div id="tooltips-container">
        <!-- temporary div, vanishes when tooltips vanishes -->
        <div>my tooltip</div>
    </div>
<body>
```

## Introduction

Tooltips in our app were taking `>80ms`. And during this time, the main thread was blocked, you couldn’t interact with anything.

Other components like modal, popover, dropdown had similar performance issues. In some cases, a modal took more than 1 second to appear while making the UI unresponsive.

![Tooltip performance timeline](https://blotcdn.com/blog_b4fa49aeb5e8474db144c0adb7862258/_image_cache/42567303-439e-4b20-84dc-0acd2179691e.png)

The main reason for the slowness of Tooltip was `Recalculate Style` being called at the end of mouseover event call stack which takes a lot of time.

![Recalculate Style in Performance Timeline](https://blotcdn.com/blog_b4fa49aeb5e8474db144c0adb7862258/_image_cache/3606e18e-5417-417f-b564-bb34f04c4407.png)

I noticed the tooltip performance was inversely proportional to number of DOM nodes currently in document.

![Performance DOM Nodes graph](https://blotcdn.com/blog_b4fa49aeb5e8474db144c0adb7862258/_image_cache/cd5638e6-8875-4b3a-9792-16487dcb001b.png)

Before diving deep into the investigation, we’ll talk about some prerequisites.

## How browser works at page load

The browser creates a DOM tree from the HTML string.

![Dom Tree from HTML String](https://blotcdn.com/blog_b4fa49aeb5e8474db144c0adb7862258/_image_cache/a4dd83f7-1c48-4436-96c1-28fad953701d.png)

You can see it as `Parse HTML` in the performance timeline in Chrome DevTools.

![Parse HTML in Performance Timeline](https://blotcdn.com/blog_b4fa49aeb5e8474db144c0adb7862258/_image_cache/0b42e521-5df8-4d57-b119-144d6018e3a2.png)

Then the CSS is parsed and browser creates the CSSOM (CSS Object Model).

![CSS Object Model](https://blotcdn.com/blog_b4fa49aeb5e8474db144c0adb7862258/_image_cache/c143d55c-fe22-4528-a9af-79d83c0b3fe1.jpg)

Then the browser combines the DOM and CSSOM to create the `render tree`.

![Render Tree](https://blotcdn.com/blog_b4fa49aeb5e8474db144c0adb7862258/_image_cache/4909edcc-87b1-41c1-b5a4-00e87b1c9f8b.png)

Render tree consists of elements currently visible on the page. Elements with property like `display: none` are not part of the `render tree`. If we have a pseudo element in CSS like `after`, then it is part of the `render tree` although it is not a part of the DOM. The creation/modification of the render tree is called `Recalculate Style` in the performance timeline.

![Recalculate Style in Performance Timeline](https://blotcdn.com/blog_b4fa49aeb5e8474db144c0adb7862258/_image_cache/3606e18e-5417-417f-b564-bb34f04c4407.png)

> Important: render tree is invalidated when we modify DOM or change styles of any element.

Next step is `layout`. Layout is calculating the size and positions of elements of the render tree, to know where we have to draw exactly. This is referred to as `Layout` in the performance timeline.

![Layout in Performance Timeline](https://blotcdn.com/blog_b4fa49aeb5e8474db144c0adb7862258/_image_cache/d3ddd1c8-5f55-47c6-827f-ce0dc234c312.png)

`Layout` may need to be done again whenever there is a change in size/position of an element which affects the position of all the elements in the page. `Layout` is also known as `Reflow`.

Next steps are `paint` and `composting` but we won’t talk about them here as they are not important for explanation of this topic.

## How browser handles rendering during runtime

![Rendering during Runtime](https://blotcdn.com/blog_b4fa49aeb5e8474db144c0adb7862258/_image_cache/cfbe79de-9352-4f32-a8af-f839a1784eef.jpg)

To render a frame in a browser, we go in this order: JavaScript runs, then there are style calculation, then layout. Ignore Paint and Composite for now.

When we access any layout property like `offsetWidth`, `offsetParent`, `width` etc, the browser returns the value from previously calculated layout calculations, which is not expensive as the calculation was done earlier in the previous frame and now we are just reading it.

But what happens when we change a style on an element or modify the DOM? Then the browser has its own heuristics and is smart enough to know if the browser needs to `Recalculate Style`/`Layout` in the current frame or defer it for later.

You can see it as `Schedule Style Recalculation` in timeline.

![Schedule Style Recalculation in Timeline](https://blotcdn.com/blog_b4fa49aeb5e8474db144c0adb7862258/_image_cache/576e35de-1548-4fa9-bc6b-1bc0414a0f3f.png)

The problem happens when we try to access a layout property just after we change style/modify DOM. Then the browser has to force `Recalculate Style`/`Layout` because browser has to return the current value, it cannot give you a stale value from the previous frame. This causes the problem known as `Layout Thrashing`.

Example:  
**BAD**  
Here we are first changing the style and then immediately reading `offsetHeight` property which causes a `Synchronous Forced Layout`.

![Bad Style Change](https://blotcdn.com/blog_b4fa49aeb5e8474db144c0adb7862258/_image_cache/aae26576-396d-46cf-9265-be3f12ebb40b.png)

You’ll see this warning in browser when this happens:

![Forced Reflow Warning](https://blotcdn.com/blog_b4fa49aeb5e8474db144c0adb7862258/_image_cache/911239f1-6cc4-4ef2-a40d-66d011330b4f.png)

**GOOD**  
This is fine because we are first reading the layout property `offsetHeight` which is not expensive as we are just reading the value from the previous frame’s layout calculation. Then we change the style later which is ok, the layout might still happen but it is up to browser when to do it, it is not forced.

![Good Style Change](https://blotcdn.com/blog_b4fa49aeb5e8474db144c0adb7862258/_image_cache/aca72dbb-333e-4d2f-8e3b-2da94edae8f2.png)

## Investigation

![Recalculate Style summary](https://blotcdn.com/blog_b4fa49aeb5e8474db144c0adb7862258/_image_cache/79ed6c15-e9b3-4929-a75e-3d74dba53232.png)

Here we can see a lot of elements (807) are affected by the `Recalculate Style` event which is the reason this event takes a lot of time.

The `Call Stacks` detail gives us important information to debug this issue. The field `Recalculation Forced` shows the value `getOffsetParent @ popper.js:188` which is this code in `popper.js@1.15.0`:

```
var offsetPArent = element.offsetParent;
```

The next field is `First Invalidation` with value `Tooltip._this.handleEnter @ Tooltip.js:165` in `@material-ui/core@3.9.3`

```
_this.childrenRef.setAttribute('title', '');
```

The `First Invalidation` shows where in code, the render tree was first invalidated which later caused `Forced Reflow` because of `Recalculation Forced` code.

Doing `setAttribute` on an element in DOM is invalidating the render tree, and then accessing the `offsetParent` causes a `Forced Synchronous Layout`.

Had this been done in the opposite direction it wouldn’t be a problem.

I tried commenting the setAttribute code and again ran the performance timeline. But the issue was still there but the invalidation was happening somewhere else.

![First Invalidated](https://blotcdn.com/blog_b4fa49aeb5e8474db144c0adb7862258/_image_cache/054069df-d88a-417e-a652-c8d729894adf.png)

The invalidation is now happening when the tooltip is added to the body, which invalidates the render tree.

Popper accessing the `offsetParent` property after attaching the tooltip to the body is causing `Forced Reflow`. If this property access was done before attaching the tooltip to body, the reflow would not have happened.

But all this is not in our control as the code is in the third party library `popper.js`.

Now, what can we do?

Next, I created a separate container in the body where the tooltip would always be attached.

```
<body>
    <div id="tooltips-container"></div>
    <div id="myapp">...</div>
</body>
```

And instructed the popper to be rendered in this container.

```
const popperProps = {
    container: () => document.getElementById('tooltips-container'),
};
```

Now, the performance was greatly improved, the `Recalculate Style` still happened but its cost was less than before. 0.79ms down from 66.57ms:

![Low Recalculate Style](https://blotcdn.com/blog_b4fa49aeb5e8474db144c0adb7862258/_image_cache/2814d1be-d234-4224-8999-24d32e64605e.png)

What happened here? The tooltip was attached to the tooltip container and not to the body. This invalidated a much smaller subtree, which was the tooltip container. The tooltip container is not visible in the page, so modifying it doesn’t invalidate the complete page render tree. If the tooltip container would have been visible in the page, then the complete render tree would be invalidated but in this case only an independent subtree was invalidated. `Recalculating Style` for a small subtree of 3 doesn’t take a lot of time and hence is faster.

The `Element Affected` is 3 which is the number of nodes inside our tooltip container.

```
<div id="tooltips-container">
    <div role="tooltip" ...>
        <div class="MuiTooltip-tooltip-66 ..." ...>
            <span class="tooltipContent__..">
                Tooltip Text
            </span>
        </div>
    </div>
</div>
```

The ideal case would be to not access the `offsetParent` property in DOM after attaching it, but it is needed for `popper.js` to calculate where to render the tooltip.

Popper.js first attaches the tooltip to the body and then moves it to the correct position. If popper.js calculated the position first and then attached the tooltip to the body later, then we wouldn’t have this problem of `Recalculate Style`. We are using an older version of `@material-ui/core@3.9.3` which is using an old version of `popper.js@1.15.0`.

## Result

The mouseover event in our tooltip finishes much quickly and does not cause jank in the experience.  
**8ms from 80ms**  
Tooltips are now **10x faster**.

![Final Result](https://blotcdn.com/blog_b4fa49aeb5e8474db144c0adb7862258/_image_cache/1e35b3bb-c083-4da7-9c2c-8797bab83e12.png)

## References

- [Website Performance Optimization - Udacity](https://www.udacity.com/course/website-performance-optimization--ud884) (App load optimization)
- [Browser Rendering Optimization - Udacity](https://www.udacity.com/course/browser-rendering-optimization--ud860) (App runtime optimization)
- [Avoid forced synchronous layouts - Web Fundamentals Google](https://developers.google.com/web/fundamentals/performance/rendering/avoid-large-complex-layouts-and-layout-thrashing?utm_source=devtools#avoid_forced_synchronous_layouts)
- [What DOM element property access forces layout / reflow](https://gist.github.com/paulirish/5d52fb081b3570c81e3a)