Skip to main content
Ben Nadel at CF Summit West 2024 (Las Vegas) with: Brian Chouteau
Ben Nadel at CF Summit West 2024 (Las Vegas) with: Brian Chouteau

Lazy Loading Images With The IntersectionObserver API In Angular 5.0.0

By
Published in Comments (20)

The other day, I was listening to a podcast about Flexbox with Rachel Andrew and Jen Simmons in which one of them (I can't remember which one) mentioned something called the IntersectionObserver API. From what they described, the IntersectionObserver API sounded like something that would be perfect for high-performance lazy-loading of images. In the past, I've looked at lazy-loading images in Angular.js 1.x; and, it's a complicated choreography of scroll actions, timers, life-cycle hooks, and the calculation of many bounding boxes. Which is exactly what the IntersectionObserver API is designed to encapsulate! As such, I wanted to see how we might be able to make the IntersectionObserver API available through a set of Angular 5 directives and services.

Run this demo in my JavaScript Demos project on GitHub.

The goal of the IntersectionObserver API is to track the visibility of a set of targets in relation to a given viewport. The viewport may be the browser's viewport; or, it may be an arbitrary element in the DOM that contains the target elements. As consumers of the API, we define the observer root, the target elements, and provide a callback. The IntersectionObserver then invokes our callback whenever the visibility of some subset of the target elements changes.

It's easy to see that 90% of the lazy-loading-images task is being lifted off of our shoulders by the IntersectionObserver API. The last 10% of lazy-loading images requires us to tell the observer when to start and stop watching a given target; and, how to update the IMG src property when said target becomes visible. To do this, I'm going to create a [lazySrc] attribute directive that will replace the IMG element's native src attribute. This directive will then register itself with an IntersectionObserver-based abstraction that will turn around and call the directive when the associated element has become visible:

// Import the core angular services.
import { Directive } from "@angular/core";
import { ElementRef } from "@angular/core";
import { OnDestroy } from "@angular/core";
import { OnInit } from "@angular/core";
import { Renderer2 } from "@angular/core";

// Import the application components and services.
import { LazyTarget } from "./lazy-viewport.ts";
import { LazyViewport } from "./lazy-viewport.ts";

// ----------------------------------------------------------------------------------- //
// ----------------------------------------------------------------------------------- //

@Directive({
	selector: "[lazySrc]",
	inputs: [
		"src: lazySrc",
		"visibleClass: lazySrcVisible"
	]
})
export class LazySrcDirective implements OnInit, OnDestroy, LazyTarget {

	public element: Element;
	public src: string;
	public visibleClass: string;

	private lazyViewport: LazyViewport;
	private renderer: Renderer2;

	// I initialize the lazy-src directive.
	constructor(
		elementRef: ElementRef,
		lazyViewport: LazyViewport,
		renderer: Renderer2
		) {

		this.element = elementRef.nativeElement;
		this.lazyViewport = lazyViewport;
		this.renderer = renderer;

		this.src = "";
		this.visibleClass = "";

	}

	// ---
	// PUBLIC METHODS.
	// ---

	// I get called once when the directive is being destroyed.
	public ngOnDestroy() : void {

		// If we haven't detached from the LazyViewport, do so now.
		( this.lazyViewport ) && this.lazyViewport.removeTarget( this );

	}



	// I get called once after the inputs have been bound for the first time.
	public ngOnInit() : void {

		// Attached this directive the LazyViewport so that we can be alerted to changes
		// in this element's visibility on the page.
		this.lazyViewport.addTarget( this );

	}


	// I get called by the LazyViewport service when the element associated with this
	// directive has its visibility changed.
	public updateVisibility( isVisible: boolean, ratio: number ) : void {

		// When this target starts being tracked by the viewport, the initial visibility
		// will be reported, even if it is not visible. As such, let's ignore the first
		// visibility update.
		if ( ! isVisible ) {

			return;

		}

		// Now that the element is visible, load the underlying SRC value. And, since we
		// no longer need to worry about loading, we can detach from the LazyViewport.
		this.lazyViewport.removeTarget( this );
		this.lazyViewport = null;
		this.renderer.setProperty( this.element, "src", this.src );

		// If an active class has been provided, add it to the element.
		( this.visibleClass ) && this.renderer.addClass( this.element, this.visibleClass );

	}

}

A lot of this directive is boiler-plate(ish). But, if you look at the ngOnInit() life-cycle hook, you can see that this directive is registering itself with an injected instance of the LazyViewport service:

this.lazyViewport.addTarget( this );

The LazyViewport service requires that the registered directive implement the LazyTarget interface, which exposes the the Element to target and an updateVisibility() callback method. The LazyViewport service then configures the IntersectionObserver - if it's supported by the browser - registeres its own callback and, when the callback is invoked, turns around and invokes the updateVisibility() method associated with the targeted element's directive instance.

export interface LazyTarget {
	element: Element;
	updateVisibility: ( isVisible: boolean, ratio: number ) => void;
}

export class LazyViewport {

	private observer: IntersectionObserver;
	private targets: Map<Element, LazyTarget>;

	// I initialize the lazy-viewport service.
	constructor() {

		this.observer = null;

		// The IntersectionObserver watches Elements. However, when an element visibility
		// changes, we have to alert an Angular Directive instance. As such, we're going
		// to keep a map of Elements-to-Directives. This way, when our observer callback
		// is invoked, we'll be able to extract the appropriate Directive from the
		// Element-based observer entries collection.
		this.targets = new Map();

	}

	// ---
	// PUBLIC METHODS.
	// ---

	// I add the given LazyTarget implementation to the collection of objects being
	// tracked by the IntersectionObserver.
	public addTarget( target: LazyTarget ) : void {

		if ( this.observer ) {

			this.targets.set( target.element, target );
			this.observer.observe( target.element );

		// If we don't actually have an observer (lacking browser support), then we're
		// going to punt on the feature for now and just immediately tell the target
		// that it is visible on the page.
		} else {

			target.updateVisibility( true, 1.0 );

		}

	}


	// I setup the IntersectionObserver with the given element as the root.
	public setup( element: Element = null, offset: number = 0 ) : void {

		// While the IntersectionObserver is supported in the modern browsers, it will
		// never be added to Internet Explorer (IE) and is not in my version of Safari
		// (at the time of this post). As such, we'll only use it if it's available.
		// And, if it's not, we'll fall-back to non-lazy behaviors.
		if ( ! global[ "IntersectionObserver" ] ) {

			return;

		}

		this.observer = new IntersectionObserver(
			this.handleIntersectionUpdate,
			{
				root: element,
				rootMargin: `${ offset }px`
			}
		);

	}


	// I remove the given LazyTarget implementation from the collection of objects being
	// tracked by the IntersectionObserver.
	public removeTarget( target: LazyTarget ) : void {

		// If the IntersectionObserver isn't supported, we never started tracking the
		// given target in the first place.
		if ( this.observer ) {

			this.targets.delete( target.element );
			this.observer.unobserve( target.element );

		}

	}


	// I teardown this service instance.
	public teardown() : void {

		if ( this.observer ) {

			this.observer.disconnect();
			this.observer = null;

		}

		this.targets.clear();
		this.targets = null;

	}

	// ---
	// PRIVATE METHODS.
	// ---

	// I handle changes in the visibility for elements being tracked by the intersection
	// observer.
	// --
	// CAUTION: Using fat-arrow binding for method.
	private handleIntersectionUpdate = ( entries: IntersectionObserverEntry[] ) : void => {

		for ( var entry of entries ) {

			var lazyTarget = this.targets.get( entry.target );

			( lazyTarget ) && lazyTarget.updateVisibility(
				entry.isIntersecting,
				entry.intersectionRatio
			);

		}

	}

}

As of this writing, the IntersectionObserver API is only supported in my Chrome and Firefox browsers. My Safari browser, which I think is a version behind, doesn't support it. And, IE will never support it. As such, my LazyViewport service has to be a bit defensive. There is an official IntersectionObserver polyfill, but I haven't tried it. That said, most of the code in this service just adds and removes targets - it doesn't actually handle any of the intersection logic - that's all done by the IntersectionObserver API.

By default, the injected LazyViewport instance will be associated with the browser's viewport because we don't provide a root element when providing the LazyViewport service in the LazyModule:

// Import the core angular services.
import { NgModule } from "@angular/core";

// Import the application components and services.
import { LazySrcDirective } from "./lazy-src.directive.ts";
import { LazyViewport } from "./lazy-viewport.ts";
import { LazyViewportDirective } from "./lazy-viewport.directive.ts";

// ----------------------------------------------------------------------------------- //
// ----------------------------------------------------------------------------------- //

@NgModule({
	declarations: [
		LazySrcDirective,
		LazyViewportDirective
	],
	exports: [
		LazySrcDirective,
		LazyViewportDirective
	],
	providers: [
		// Setup the default LazyViewport instance without an associated element. This
		// will create a IntersectionObserver that uses the browser's viewport as the
		// observer root. This way, an instance of LazyViewport is always available for
		// injection into other directives and services.
		// --
		// NOTE: This service will be overridden at lower-levels in the component tree
		// whenever a [lazyViewport] directive is applied.
		{
			provide: LazyViewport,
			useFactory: function() {

				var viewport = new LazyViewport();
				viewport.setup( /* No root. */ );

				return( viewport );

			}
		}
	]
})
export class LazyModule {
	// ...
}

... but, here's where Angular's dependency-injection gets really exciting: any element in the component tree can override the LazyViewport provider, thereby causing descendant [lazySrc] instances to be associated with a more local viewport. To facilitate overriding of the LazyViewport service in a subtree of the component graph, I created a [lazyViewport] directive:

// Import the core angular services.
import { Directive } from "@angular/core";
import { ElementRef } from "@angular/core";
import { OnDestroy } from "@angular/core";
import { OnInit } from "@angular/core";

// Import the application components and services.
import { LazyViewport } from "./lazy-viewport.ts";

// ----------------------------------------------------------------------------------- //
// ----------------------------------------------------------------------------------- //

@Directive({
	selector: "[lazyViewport]",
	inputs: [ "offset: lazyViewportOffset" ],

	// The primary role of this directive is to override the default LazyViewport
	// instance at this point in the component tree. This way, any lazy-directives
	// that are descendants of this element will receive this instance when using
	// dependency-injection.
	providers: [
		{
			provide: LazyViewport,
			useClass: LazyViewport
		}
	]
})
export class LazyViewportDirective implements OnInit, OnDestroy {

	public offset: number;

	private elementRef: ElementRef;
	private lazyViewport: LazyViewport;

	// I initialize the lazy-viewport directive.
	constructor(
		elementRef: ElementRef,
		lazyViewport: LazyViewport
		) {

		this.elementRef = elementRef;
		this.lazyViewport = lazyViewport;
		this.offset = 0;

	}

	// ---
	// PUBLIC METHODS.
	// ---

	// I get called once when the directive is being destroyed.
	public ngOnDestroy() : void {

		this.lazyViewport.teardown();

	}


	// I get called once after the inputs have been bound for the first time.
	public ngOnInit() : void {

		// Ensure that the offset value is numeric when we go to initialize the viewport.
		if ( isNaN( +this.offset ) ) {

			console.warn( new Error( `[lazyViewportOffset] must be a number. Currently defined as [${ this.offset }].` ) );
			this.offset = 0;

		}

		// Now that this LazyViewport directive has overridden the instance of
		// LazyViewport in the dependency-injection tree, we have to initialize it
		// to use the current element as the observer root.
		this.lazyViewport.setup( this.elementRef.nativeElement, +this.offset );

	}

}

As you can see, this directive defines a providers collection which overrides the injectable service associated with the LazyViewport dependency-injection token. By doing this, any [lazySrc] directive lower down in the component tree will get this LazyService override-instance rather than the global one:

this.lazyViewport.setup( this.elementRef.nativeElement, +this.offset );

At this time, I think it makes more sense to always go with the global instance. But, I just love how Angular's dependency-injection tree makes it so easy to change the viewport. Dependency-injection is just such a win!

Once I import the LazyModule into my AppModule (not worth showing), I can then use the [lazySrc] directive on my IMG tags. In this case, I've created a scrolling list of contacts with avatars. The avatars will only load when the contact is scrolled into view:

// Import the core angular services.
import { Component } from "@angular/core";

// ----------------------------------------------------------------------------------- //
// ----------------------------------------------------------------------------------- //

interface Contact {
	id: number;
	name: string;
	avatarUrl: string;
}

@Component({
	selector: "my-app",
	styleUrls: [ "./app.component.less" ],
	template:
	`
		<p>
			<a (click)="toggleContacts()">Toggle Contacts</a>
		</p>

		<ul *ngIf="isShowingContacts" class="contacts">
			<li *ngFor="let contact of contacts">

				<img [lazySrc]="contact.avatarUrl" lazySrcVisible="visible" />
				<span>{{ contact.name }} - {{ contact.id }}</span>

			</li>
		</ul>

		<p>
			<a (click)="popContact()">Pop Contact</a>
			&mdash;
			<a (click)="pushContact()">Push Contact</a>
		</p>
	`
})
export class AppComponent {

	public contacts: Contact[];
	public isShowingContacts: boolean;
	public maxID: number;

	// I initialize the app component.
	constructor() {

		this.contacts = [];
		this.isShowingContacts = false;
		this.maxID = 0;

		for ( var i = 1 ; i < 50 ; i++ ) {

			this.pushContact();

		}

	}

	// ---
	// PUBLIC METHODS.
	// ---

	// I remove a contact from the top of the collection.
	public popContact() : void {

		this.contacts.shift();

	}


	// I add a new contact to the bottom of the collection.
	public pushContact() : void {

		this.contacts.push({
			id: ++this.maxID,
			name: "Frances McDormand",
			avatarUrl: `./app/frances-mcdormand.jpg?id=${ this.maxID }`
		});

	}


	// I toggle the showing of the contact list.
	public toggleContacts() : void {

		this.isShowingContacts = ! this.isShowingContacts;

	}

}

As you can see, instead of using a traditional src attribute, my IMG tag is using the [lazySrc] directive:

<img [lazySrc]="contact.avatarUrl" lazySrcVisible="visible" />

Now, when we load the application and toggle the contact list, we can see from the network activity that only the visible portion of the avatars have loaded:

Lazy loading images with the IntersectionObserver API in Angular 5.

As you can see from the network activity, only the four visible avatars have actually been loaded over the network. The rest don't get loaded until you scroll them into view, at which time the IntersectionObserver API let's our LazyViewport service know; which, in turn, let's our [lazySrc] directives know.

That is just insanely awesome! And, if we start using the polyfilly, then the code gets even more simple as we can make more assumptions about support and code less defensively.

As a final note, passing around Document Object Model elements is always a little stressful because this is where so many memory leaks have come from historically. As such, I wanted to peek at the memory usage to see how the IntersectionObserver behaves when adding and removing targets. The following graph is Chrome's performance monitoring that I recorded while toggling the contact list into and out of existence.

Lazy loading images and memory usage with the IntersectionObserver API in Angular 5.

As you can see, when I toggled the contact list, thereby adding and removing targets to and from the IntersectionObserver, respectively, the memory graph jumped up as we would expect. But, the important part is that when I triggered a Garbage Collection, the baseline memory usage dropped back down to the same level, indicating that there are no values suck in memory purgatory.

I have to say, I am really loving this IntersectionObserver API. It has solid support in the most popular browsers; and, it has polyfills that can be applied in lieu of support. It's going to make lazy-loading of images (and other types of content) so much easier.

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

Reader Comments

1 Comments

I am getting following errors

ERROR in ../modules/lazy-load/lazy-viewport.ts(57,10): error TS2304: Cannot find name 'global'.

../modules/lazy-load/lazy-viewport.ts(121,11): error TS2339: Property 'isIntersecting' does not exist on type 'IntersectionObserverEntry'.

Can you please help me on this.

Thanks

15,848 Comments

@Zafar,

Try replacing "global" with "window". I'm just trying to see if the feature exists in the current browser. I'm curious as to why that works for me and not for your. How are you compiling your code? I'm using Webpack. Perhaps you're doing some Ahead of Time (AoT) compiling that is not compatible with the "global" name?

15,848 Comments

@Deep,

I am not sure why you guys are seeing that error. It seems to be a TypeScript error, so it's not an issue with your browser. Perhaps it is something in the tsconfig -- here's what I am using:

{
. . "compilerOptions": {
. . . . "emitDecoratorMetadata": true,
. . . . "experimentalDecorators": true,
. . . . "lib": [
. . . . . . "DOM",
. . . . . . "ES6"
. . . . ],
. . . . "module": "commonjs",
. . . . "moduleResolution": "node",
. . . . "noImplicitAny": true,
. . . . "pretty": true,
. . . . "removeComments": false,
. . . . "sourceMap": true,
. . . . "suppressImplicitAnyIndexErrors": true,
. . . . "target": "es5",
. . . . "types": [
. . . . . . "node"
. . . . ]
. . }
}

Anything here look suspicious to you?

3 Comments

Hi Ben,

Thanks for your blog post, is very good and help me a lot to understand how this new API works!

I've tried to implement this solution on my project but I'm receiving the same error as Deep and Zafar.

"error TS2339: Property 'isIntersecting' does not exist on type 'IntersectionObserverEntry'."

it seems like typescript is not able to compile this bit and I can confirm that I'm doing some AoT built-in from Angular-Cli version 1.6.

When I "ng serve -aot" it runs all good, the problem happens when I "ng build -prod" or just "ng build".

The following is my tsconfig.json:
{
"compileOnSave": false,
"compilerOptions": {
"outDir": "./dist/out-tsc",
"sourceMap": true,
"declaration": false,
"moduleResolution": "node",
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"target": "es5",
"typeRoots": [
"node_modules/@types"
],
"lib": [
"es2017",
"dom"
]
}
}

I've tried to find everywhere the answer, but now I run out of resources... Do you have any idea what is happening?

Cheers,

Luiz

15,848 Comments

@Luiz,

Sorry, unfortunately I don't have any advice on this. I haven't dug into the Ahead of Time compiler yet, so I don't know where the differences have an impact. It seems so odd that there would be such a difference (though this is not the first time that I've seen things breaking for people who are trying to do AoT work in Angular).

If you do figure it out, please keep us updated!

3 Comments

Thanks a lot Ben!! Will keep my quest into it, and if I can find the solution and the reason why for it to be hapening will post it here!!

Thanks again for your time and for the great blog!!

1 Comments

@Luiz,

I changed the method a little, so TypeScript will like it.
Here we go:

private handleIntersectionUpdate = (entries: IntersectionObserverEntry[]): void => {
_.forEach(entries, (entry: any) => {
const lazyTarget = this._targets.get(entry.target) as LazyTarget;
if (lazyTarget) {
lazyTarget.updateVisibility(entry.isIntersecting, entry.intersectionRatio);
}
});
}

3 Comments

Hi @Daniel,

Thanks for the help! It seems that now Typescript is recognizing the variable isIntersecting!!

I had just to change the forEach for the native for loop, as my typescript was throwing an error on the "_" of the "_.forEach".

Now it is building perfectly!!

Thanks a lot, @Daniel and @Ben for the help!

Cheers,
Luiz

15,848 Comments

Ah, it looks like you are casting the "entry" as type "any". So, this inherently stops TypeScript from type-checking the value and its consumption. So, you're essentially side-stepping the type error.

Not saying that this is a bad approach -- just trying to articulate why the change worked. Sometimes, you just have to side-step the type-checker.

1 Comments

I am not able to use this on IOS safari browsser and chrome browser, i am using webpack for the polyfills, do i need to include any extra polyfills for the desired behavior.

1 Comments

If anyone wants to apply this to a <picture> element, I can confirm it works. Just copy the lazySrc directive to a lazySrcSet directive and use that for the <source> element.

8 Comments

hi,

Your example in your demo page does not work on my safari (12) nor on my chrome on my supercool new generation MacBook Pro.
It loads all images at once. What's wrong?

Thank you in advance
Antonio

8 Comments

Sorry, it works on Chrome, but not on Safari. I think that IntersectionObserver is not supported on Safari.

8 Comments

HELP!!!

I'm trying to make this code work on my online stores. It works like a charm on chrome, except if it's in combination with an angular carousel library (ngx-owl-carousel-o). I mean, images inside the angular carousel do not work

The weird part is that, trying lot of stuffs one day I put a console.log in the LazySrcDirective ngOnInit method:

    public ngOnInit() : void {

        // Attached this directive the LazyViewport so that we can be alerted to changes
        // in this element's visibility on the page.
        this.lazyViewport.addTarget( this );
        console.log(this);

    }

And with the console.log it works, but without the console.log it doesn't. It works in everywhere, except in the carousel, but with the console.log it works!

Any clue of what's wrong?

Thank you
Antonio

15,848 Comments

@Antuan,

That's so crazy! Off the top of my head, I can't understand why it would work with console.log(), but not without it. My only other thought that something about the Carousel is either messing up the ability for the Intersection Observer to see the images? Or, maybe it Observer is always seeing them, so it never actually emits any changes?

In the handleIntersectionUpdate() method, maybe try adding a logging statement there to see if there is any update for visibility being called?

Sorry I don't have any better advice - it's strange that it doesn't work.

8 Comments

The carousel allow you to specify the number of visible items per page. If I configure the carousel to show 5 by 5 items, it fails as I have described.
If I configure whatever other number (have already tried with 3, with 4, with 2, with 6), the intersection observer works like a charm. Problem is when you configure it to show 5 items...
No clues in the carousel code, no one answer my open issues in the carousel GitHub project.
Just to let you know, so weird... ah, and if I put a log just at the end of ngOnInit as I described in my previous post, it works.

Not sure, but... black magic?

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