IntersectionObserver API Performance: Many vs. Shared In Angular 11.0.5
Just before Christmas, I started to experiment with using NgSwitch
and the IntersectionObserver
API to defer template bindings in Angular. The hope being that I could reduce digest complexity which may lead to better performance when dealing with very large data-sets. In that experiment, each Element received its own instance of the IntersectionObserver
; which, at the end, may have lead to its own performance bottleneck. To dig a little deeper into this latter thought, I wanted to compare performance when using many IntersectionObserver
instances vs. one shared instance for a large data-set in Angular 11.0.5.
Run this demo in my JavaScript Demos project on GitHub.
View this code in my JavaScript Demos project on GitHub.
In order to test the performance of many vs. a shared IntersectionObserver
instance, I'm going to revamp my previous demo to include a version in which the UL
(List) element creates an IntersectionObserver
instance which is then injected (so to speak) into the LI
(Item) elements. I'm then going to be able to switch back-and-forth between the two different versions and, using Chrome's Dev Tools Performance features, see how they two different approaches compare.
The first version of the demo uses the approach from the previous post in which each LI
(Item) element uses its own instance of the IntersectionObserver
API:
<h2>
Demo A Component
</h2>
<p>
Using one <code>IntersectionObserver</code> instance <strong>per Element</strong>.
</p>
<!-- In this demo, every LI element gets its own IntersectionObserver. -->
<ul class="items">
<li
*ngFor="let value of items"
class="items__item"
bnIntersectionObserverA
#intersection="intersection"
[ngSwitch]="intersection.isIntersecting">
<span *ngSwitchCase="intersection.IS_INTERSECTING">
{{ value }}
</span>
</li>
</ul>
As you can see, each LI
has its own bnIntersectionObserverA
which, as we'll see in a minute, instantiates its own IntersectionObserver
instance which then calls .observe()
on its own host element.
Now, in the second version of this demo, the overall structures is the same; however, the UL
element also has a directive which works in conjunction with the LI
directive to share a single instance of the IntersectionObserver
API:
<h2>
Demo B Component
</h2>
<p>
Using one <code>IntersectionObserver</code> instance <strong>per List</strong>.
</p>
<!--
In this demo, there is an IntersectionObserver provided by the UL element. Then,
every LI element injects the UL-provided instance.
-->
<ul bnIntersectionObserverBList class="items">
<li
*ngFor="let value of items"
class="items__item"
bnIntersectionObserverB
#intersection="intersection"
[ngSwitch]="intersection.isIntersecting">
<span *ngSwitchCase="intersection.IS_INTERSECTING">
{{ value }}
</span>
</li>
</ul>
As you can see in this version, there are two directives at play:
bnIntersectionObserverBList
- Located on theUL
element, this directive instantiates the sharedIntersectionObserver
API. This directive instance is then injected into theLI
directive.bnIntersectionObserverB
- Located on theLI
elements, this directive uses the aforementionedUL
directive to observe intersection changes in its own host element(s).
In the end, both approaches are feeding into the NgSwitch
/ NgSwitchCase
directives that are deferring template bindings for the {{value}}
interpolation within the LI
content.
The App component then allows me to switch back-and-forth between these two demo components:
<p>
<strong>Show Demo</strong>:
<a (click)="showDemo( 'OnePerElement' )">One per Element</a> ,
<a (click)="showDemo( 'OnePerList' )">One per List</a>
</p>
<div [ngSwitch]="demo">
<demo-a *ngSwitchCase="( 'OnePerElement' )"></demo-a>
<demo-b *ngSwitchCase="( 'OnePerList' )"></demo-b>
</div>
Now that we see how the templates work, let's dive into the actual IntersectionObserver
manifestation. The first directive, which grants an individual instance of the IntersectionObserver
to each LI
element is basically a copy from my previous blog post. In it, it passes the host element to the .observe()
method, which then alters the public isIntersecting
property that we're ultimately consuming in the above NgSwitch
statement:
// Import the core angular services.
import { Directive } from "@angular/core";
import { ElementRef } from "@angular/core";
// ----------------------------------------------------------------------------------- //
// ----------------------------------------------------------------------------------- //
@Directive({
selector: "[bnIntersectionObserverA]",
exportAs: "intersection"
})
export class IntersectionObserverADirective {
public isIntersecting: boolean;
// These are just some human-friendly constants to make the HTML template a bit more
// readable when being consumed as part of SWTCH/CASE statements.
public IS_INTERSECTING: boolean = true;
public IS_NOT_INTERSECTING: boolean = false;
private elementRef: ElementRef;
private observer: IntersectionObserver | null;
// I initialize the intersection observer directive.
constructor( elementRef: ElementRef ) {
this.elementRef = elementRef;
this.observer = null;
// By default, we're going to assume that the host element is NOT intersecting.
// Then, we'll use the IntersectionObserver to asynchronously check for changes
// in viewport visibility.
this.isIntersecting = false;
}
// ---
// PUBLIC METHODS.
// ---
// I get called once when the host element is being destroyed.
public ngOnDestroy() : void {
this.observer?.disconnect();
this.observer = null;
}
// I get called once after the inputs have been bound for the first time.
public ngOnInit() : void {
this.observer = new IntersectionObserver(
( entries: IntersectionObserverEntry[] ) => {
// CAUTION: Since we know that we have a 1:1 Observer to Target, we can
// safely assume that the entries array only has one item.
this.isIntersecting = entries[ 0 ].isIntersecting;
},
{
// This classifies the "intersection" as being a bit outside the
// viewport. The intent here is give the elements a little time to react
// to the change before the element is actually visible to the user.
rootMargin: "300px 0px 300px 0px"
}
);
this.observer.observe( this.elementRef.nativeElement );
}
}
In the second demo, we have two directives that work in tandem to provide the deferred binding. Since these work hand-in-hand, I'm defining them in the same TypeScript file. The first directive is the one bound to the UL
(List) element; the second directive is the one bound to the LI
(Item) element(s). The former is then dependency-injected into the latter:
// Import the core angular services.
import { Directive } from "@angular/core";
import { ElementRef } from "@angular/core";
// ----------------------------------------------------------------------------------- //
// ----------------------------------------------------------------------------------- //
@Directive({
selector: "[bnIntersectionObserverBList]"
})
export class IntersectionObserverBListDirective {
private mapping: Map<Element, Function>;
private observer: IntersectionObserver;
// I initialize the intersection observer parent directive.
constructor() {
// As each observable child attaches itself to the parent observer, we need to
// map Elements to Callbacks so that when an Element's intersection changes,
// we'll know which callback to invoke. For this, we'll use an ES6 Map.
this.mapping = new Map();
this.observer = new IntersectionObserver(
( entries: IntersectionObserverEntry[] ) => {
for ( var entry of entries ) {
var callback = this.mapping.get( entry.target );
( callback && callback( entry.isIntersecting ) );
}
},
{
// This classifies the "intersection" as being a bit outside the
// viewport. The intent here is give the elements a little time to react
// to the change before the element is actually visible to the user.
rootMargin: "300px 0px 300px 0px"
}
);
}
// ---
// PUBLIC METHODS.
// ---
// I add the given Element for intersection observation. When the intersection status
// changes, the given callback is invoked with the new status.
public add( element: HTMLElement, callback: Function ) : void {
this.mapping.set( element, callback );
this.observer.observe( element );
}
// I get called once when the host element is being destroyed.
public ngOnDestroy() : void {
this.mapping.clear();
this.observer.disconnect();
}
// I remove the given Element from intersection observation.
public remove( element: HTMLElement ) : void {
this.mapping.delete( element );
this.observer.unobserve( element );
}
}
// ----------------------------------------------------------------------------------- //
// ----------------------------------------------------------------------------------- //
@Directive({
selector: "[bnIntersectionObserverB]",
exportAs: "intersection"
})
export class IntersectionObserverBDirective {
public isIntersecting: boolean;
// These are just some human-friendly constants to make the HTML template a bit more
// readable when being consumed as part of SWTCH/CASE statements.
public IS_INTERSECTING: boolean = true;
public IS_NOT_INTERSECTING: boolean = false;
private elementRef: ElementRef;
private parent: IntersectionObserverBListDirective;
// I initialize the intersection observer directive.
constructor(
parent: IntersectionObserverBListDirective,
elementRef: ElementRef
) {
this.parent = parent;
this.elementRef = elementRef;
// By default, we're going to assume that the host element is NOT intersecting.
// Then, we'll use the IntersectionObserver to asynchronously check for changes
// in viewport visibility.
this.isIntersecting = false;
}
// ---
// PUBLIC METHODS.
// ---
// I get called once when the host element is being destroyed.
public ngOnDestroy() : void {
this.parent.remove( this.elementRef.nativeElement );
}
// I get called once after the inputs have been bound for the first time.
public ngOnInit() : void {
// In this demo, instead of using an IntersectionObserver per Element, we're
// going to use a shared observer in the parent element. However, we're still
// going to use a CALLBACK style approach so that we're only reducing the number
// of IntersectionObserver instances, not the number of Function calls.
this.parent.add(
this.elementRef.nativeElement,
( isIntersecting: boolean ) => {
this.isIntersecting = isIntersecting;
}
);
}
}
The mechanics of this version work exactly the same as the mechanics of the first version: each LI
element is being observed for intersection changes. And, when a change is observed, a callback is invoked which updates the isIntersecting
property. The only meaningful difference is that the IntersectionObserver
API in this version is shared.
Now, if we open this up in the browser and we switch between the two demos (both of which are rendering 1,000 list items), we can immediately see a significant performance difference:
Both versions, when running in production mode, are actually quite fast. However, you can see that the first demo, which uses one InstersectionObserver
instance per LI
element is noticeably slower. In fact, in some of the toggling-over to the second demo, which uses a shared IntersectionObserver
instance, you can barely see any flashed of the deferred template-bindings.
If we then re-run this toggling with Chrome's Performance monitoring turned on, we can see the magnitude of the difference:
It takes almost one-quarter of a second to switch Demo A, which uses many instances of the IntersectionObserver
API. Contrast this with the performance when switching over to Demo B:
It's not quite an order of magnitude faster; but, it's just over 30-milliseconds to render the version of the demo in which we are using a shared instance of the IntersectionObserver
API.
At this point, it may become clear that sharing IntersectionObserver
instances has better performance. But, if we dig a bit deeper, it becomes a bit fuzzier as to where the actual performance bottleneck exists. If we go back to the Chrome Performance monitoring and look at the flame-graph of the Demo A switch, here's what we see:
Way down deep in the flame-graph we can see many, many change-detection digests. This is because we're binding our IntersectionObserver
inside the Angular Zone (NgZone
) which means that every time the handler callback is invoked, Angular runs change-detection to reconcile the template bindings. And, since Demo A has a one-to-one match of IntersectionObserver
instance to LI
(Item) elements, it means that we basically run 1,000 digests every time we switch over to Demo A.
In comparison, if we look at the flame-graph for the Demo B switch, we see this:
As you can see, when switching over to Demo B, which uses a shared IntersectionObserver
API, we have the same number of callbacks; but, we only end up triggering a single change-detection digest. This is because we only every bind a single InstersectionObserver
handler (which, in turn, invokes all of the callbacks). So, instead of having 1,000 handlers in the first demo, we have a single handler which invokes 1,000 callbacks. It's the same number of method invocations; but, two orders-of-magnitude fewer digests.
At first blush, if seems like we answer the question of performance: one shared IntersectionObserver
API is much faster than many instances of the IntersectionObserver
API. However, when we dig deeper, we can see that the performance bottleneck isn't clearly in the IntersectionObserver
- it could be that the performance bottleneck is actually the Angular template reconciliation and the number of change-detection digests. Which begs the question: what happens if we bind the IntersectionObserver
outside of the NgZone
and then mark Elements "for check" upon change? Which is exactly what I hope to explore in my next blog post.
UPDATE: Performance Difference in the Firefox Browser
I do all of my R&D in the Chrome Browser since I tend to enjoy its developer tools much more. And, after publishing this exploration, I happened to take a look at in in Firefox; and, what I saw was that the difference in performance was greatly magnified:
As you can see, in the Firefox browser, the shared instance of the IntersectionObserver
API is massively faster. Again, this may still be a change-detection issue more than an IntersectionObserver
issue; but, the performance difference here (in Firefox) is as clear as day-and-night.
Want to use code from this post? Check out the license.
Reader Comments
@All,
This morning, I tried to go into this demo and bind the
IntersectionObserver
outside theNgZone
. And then, whenever I changed the.isIntersecting
property, call thechangeDetectorRef.detectChanges()
. But, this runs into the exactly same problem: loads of change-detection digests. Since each observer runs asynchronously, all of the callbacks end up happening in different event-loop ticks, which means (I believe) that even if I am more explicit in when digests are triggered, there is still going to be a separate digest for every single element with deferred bindings.All to say, I think the one shared observer is really the only way to get the good performance on this technique.
Thanks for write up Ben. It was helpful.
@Hassam,
My pleasure! Glad you liked it. In an ideal world, you would just have less data on the screen, which means less bindings which means better performance. But, sometimes, in some apps, that's just not the feasible option :)
Great analysis. A quick note on chrome's performance monitor - the time taken specifically finding intersection changes by observers appears to take place during the composite layers phase of render. This overhead can be surprisingly high, and appears to effect every render frame for the life of the observers, not just at instantiation. I recently experimented with an idea for tracking positional mutations to elements by meshing the x/y of the viewport with observers and noted that while callbacks were handled in the <2ms range, an additional (and concept killing) ~150ms was tacked onto every repaint.
Thanks for the analysis. I'm the author of the IntersectionObserver implementation in Chrome, and I can confirm that there are some built-in performance advantages to using one observer rather than many. In your case, though, I think you're correct that the angular overhead is dominant.
@Stefan,
Having the author of the implementation leave a comment on my blog, I am flattered for your time :D :D And thank you for the insight into the one vs. many. If I may ask you a lower-level question, imagine that I have a UI that is composed of an
overflow: auto
container. Is there any benefit to me attaching theIntersectionObserver
to the overflow-container? Or, is having one instance attached to thedocument
sufficient in the vast majority of cases.To ask another way, what would even be the difference between binding to an overflow-container vs. the root document?
@Ben W.,
To be clear, you're saying that just having an
IntersectionObserver
instance be actively tracking elements has non-trivial overhead? Or, are you saying that just having any instance of it around is adding overhead? Meaning, do you think it would be OK to have an instance of Observe just exist for the whole life-time of the applications; and then, DOM elements are just being added and removed to and from it over time?Thanks for this post and the working examples! I've been wondering about this and it thoroughly answered my question. I miss working with you! I hope you + the InVision crew are all well <3
@Ted,
Hey man! So good to hear from you! Things, as always, are an adventure here (which has its ups and downs). V7 is gaining momentum; so I'm here on the V6 side of things still fighting to innovate and advocate for the customers that are still using it. There's still some fight left in this old dog :D Hope you are doing well where you are.
Hi Ben, thanks for the write up! Really helpful. Another thing I noticed in one of my apps was when using one IntersectionObserver per element in an Angular app caused a memory leak. I had a bunch of detached IntersectionObservers that just wouldn't get cleaned up by the garbage collector. Each time I hit the page kept adding more nodes. As soon as I switched to a single observer for multiple elements, there was no longer a memory leak and the objects were being cleaned up.
Another reason why I think I'll stick to a single instead of multiple!
@Ben,
To clarify, I came to two main conclusions.
Firstly, that the cost IntersectionObserver incurs performing the task of actually calculating intersections is embedded in the composite layers phase of the frame preceding the callback. The callback itself appears to be fairly well unencumbered by the workload of supplying the observations it receives.
Secondly (and far less relevantly to almost all use cases), that this internal intersection calculation process is not optimized in the case of multiple IntersectionObserver instances over the same target, even when there is little to no divergence between them. Which is to say, 1000 identically instantiated observers targeting the one element will perform the same intersection calculation for the same result 1000 times.`
@Gareth,
Ah, that makes a lot of sense. I don't fully understand how all the Garbage Collection (GC) stuff works; but, I do remember from my jQuery days that when anything touched the "DOM", GC suddenly became a lot more error-prone (or, at least in IE). All said, it just seems like the single Observer is the way to go.
@Ben W,
Groovy - thank you for the clarity. I'm just excited that using a single Intersection Observer seems to also be the easier approach in addition to the one that seems to be more performant. So, it's a win-win in my eyes.
Thank you so much for that fantastic clarification and interpretation. Just one question for the shared intersection, if you put a console in the constructor of the shared one (bnIntersectionObserverBList) just after the mapping initializing, how many times this console will be executed? Just one or based on the count of the 'li' that included inside the 'ul'.
Thanks in advance
@Ramzi,
The
bnIntersectionObserverBList
directive will be instantiated just once on theUL
. Then, that instance will be automatically made available as an dependency-injection token in each of thebnIntersectionObserverB
directive instance (theLI
attribute directive). So, theLI
directives are instantiated once per list item; but, the parent directive is only instantiated the one time.Thank you so much Ben for your prompt reply.
In fact, when I try to apply your code I got an error: "NullInjectorError: No provider for IntersectionObserverBListDirective!".
When I try to solve that error by injecting the directive into the providers like that (perhaps there is another way!?):
indeed, the error is solved but the constructor of the bnIntersectionObserverBList logs about 100 times (100 is the number of li inside the foreach). Is that ok or there is something missed?
Thanks again
@Ramzi,
It sounds like maybe the List directive isn't being included. In you "parent element" (
<ul>
in my case), make sure you're including the intersection observer directive:... note the attribute I have here - this is the selector that Angular is using to match on the container directive. If you missed that, then Angular will have nothing to inject into the list-items.
Awesome Experiment.
@Awais,
Thank you! 🙌