Skip to main content
Ben Nadel at Scotch On The Rock (SOTR) 2010 (Munich) with: Christian Etbauer
Ben Nadel at Scotch On The Rock (SOTR) 2010 (Munich) with: Christian Etbauer

IntersectionObserver API Performance: Many vs. Shared In Angular 11.0.5

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

Want to use code from this post? Check out the license.

Reader Comments

15,674 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.

15,674 Comments

@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 :)

2 Comments

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.

1 Comments

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.

15,674 Comments

@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 the IntersectionObserver to the overflow-container? Or, is having one instance attached to the document 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?

15,674 Comments

@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?

1 Comments

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

15,674 Comments

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

1 Comments

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!

2 Comments

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

15,674 Comments

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

15,674 Comments

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

2 Comments

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

15,674 Comments

@Ramzi,

The bnIntersectionObserverBList directive will be instantiated just once on the UL. Then, that instance will be automatically made available as an dependency-injection token in each of the bnIntersectionObserverB directive instance (the LI attribute directive). So, the LI directives are instantiated once per list item; but, the parent directive is only instantiated the one time.

2 Comments

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!?):

@Directive({
	selector: '[observeVisibility]',
	providers: [IntersectionObserverBListDirective]
})

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

15,674 Comments

@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:

<ul bnIntersectionObserverBList class="items">

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

I believe in love. I believe in compassion. I believe in human rights. I believe that we can afford to give more of these gifts to the world around us because it costs us nothing to be decent and kind and understanding. And, I want you to know that when you land on this site, you are accepted for who you are, no matter how you identify, what truths you live, or whatever kind of goofy shit makes you feel alive! Rock on with your bad self!
Ben Nadel