Skip to main content
Ben Nadel
On User Experience (UX) Design, JavaScript, ColdFusion, Node.js, Life, and Love.

IntersectionObserver API Performance: Many vs. Shared In Angular 11.0.5

By Ben Nadel on

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 the UL element, this directive instantiates the shared IntersectionObserver API. This directive instance is then injected into the LI directive.

  • bnIntersectionObserverB - Located on the LI elements, this directive uses the aforementioned UL 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:

Switching back-and-forth between the two IntersectionObserver demos in Angular 11.

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:

The Scripting time for Demo A is close to 1/4 of a second.

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:

The Scripting time for Demo B is just over 30-milliseconds.

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:

The flame-graph shows many change-detection digests in the first demo.

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:

The flame-graph shows a single change-detection digests in the second demo.

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:

Many vs. One IntersectionObserver performance in Firefox - shared is vastly superior!

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.



Reader Comments

@All,

This morning, I tried to go into this demo and bind the IntersectionObserver outside the NgZone. And then, whenever I changed the .isIntersecting property, call the changeDetectorRef.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.

Reply to this Comment

Post A Comment

You — Get Out Of My Dreams, Get Into My Blog
Live in the Now
Oops!
NEW: Some basic markdown formatting is now supported: bold, italic, blockquotes, lists, fenced code-blocks. Read more about markdown syntax »
Comment Etiquette: Please do not post spam. Please keep the comments on-topic. Please do not post unrelated questions or large chunks of code. And, above all, please be nice to each other - we're trying to have a good conversation here.