DEV Community

Cover image for Lazy loading images upon intersection in Angular
Ayyash
Ayyash

Posted on • Originally published at garage.sekrab.com

Lazy loading images upon intersection in Angular

Images are content, whether allowed on the server or not. They are usually controlled by host servers, or off domain data stores. Today we want to find a solution to multiple problems we usually face in small to medium size apps, right after they start picking up the pace and become harder to manage. The plan is to:

  • use browser supported mechanism if possible
  • handle large and slow images
  • have a fallback for broken images
  • serve something for SSR (search bots mainly)

Find the code on the same StackBlitz project.

We want to end up with something like this

<div class="main-bg" crLazy="/assets/images/largeimage.jpg" [options]="{threshold: 1}">some content</div>
<img crLazy="/images/frontimage.jpg" src="/images/blurredout.jpg" [options]="{threshold: 1}" />
Enter fullscreen mode Exit fullscreen mode

Following the advice for best performance and best user experience, the images that need to be lazy loaded are usually those below the fold. Yet we will have images at the top, like hero backgrounds, that need to be delayed.

HTML native attributes

First, let's get this one out of the way. The newly introduced attribute for images loading="lazy" should take care of a whole lot of issues natively. It is good, it starts downloading images a good mile before intersection, and it requests the image once. Above the fold it loads immediately. The images are their own http request and we still want to delay partially not to pinch away from anything else that matters (the image matters, but not as much as the SPA framework.) We could use decoding="async" attribute to help with that.

If JavaScript is disabled, the browser automatically downloads all images with http request. This means search bots will have access to the images.

We'll talk about what we can do with it later.

Setup

The barebone of the directive is the following:

@Directive({
  selector: '[crLazy]',
  standalone: true,
})
export class LazyDirective implements AfterViewInit {
  @Input() crLazy: string = '';

  constructor(
    private el: ElementRef,
    private renderer: Renderer2
  ) { }

  private lazyLoad(entry:IntersectionObserverEntry, observer:IntersectionObserver) {
    // when intersecting,
    if (entry.isIntersecting){
      // implement this
      // then disconnect
      observer.disconnect();
    }
  }
  ngAfterViewInit() {

    if (!this.platform.isBrowser) {
      // we'll have to do something for the server, sometimes
      return;
    }

    const io = new IntersectionObserver((entries, observer) => {
          this.lazyLoad(entries[0], observer);
        },
      {
        threshold: 0
      });
      io.observe(this.el.nativeElement);
  }
}
Enter fullscreen mode Exit fullscreen mode

We will also have a couple of elements to test with, here is the minimum HTML and CSS for this purpose:

<div class="bg-image main-bg" [crLazy]="cosmeticImage">
  <h3>Text over image</h3>
</div>

<picture class="card-figure">
  <img alt="toyota" [src]="lowresImage" [crLazy]="serverImage" />
</picture>

<style>
/*The bare minimum for background images*/
.bg-image {
  background: no-repeat center center;
  background-size: cover;
}
/* main bg starts with background color or low res image */
.main-bg {
  background-color: #ddd;
  /*we can also add a low res image by default */
  display: flex;
  align-items: center;
  justify-content: center;
}
/* example of card with known width */
.card-figure {
  width: 50vw;
  display: block;
}
.card-figure img {
  width: 100%;
}
</style>
Enter fullscreen mode Exit fullscreen mode

Set attribute on intersection

The first solution that comes to mind is to change the attribute when intersection occurs. If the element is an IMG, we change the src, otherwise we change the background image in styles attribute. After changing the image, we do not wish to observe the element. So we unobserve. Or disconnect. We will use the main property to be the image's new source. We could start with a single observer per whole window, but then we will run into a wall when threshold is needed. So we'll skip and make it an observer per directive. (So it's safe to disconnect).

// lazy load
if (entry.isIntersecting){
  // if IMG, change src
  if (entry.target.tagName === 'IMG') {
   this.renderer.setAttribute(this.el.nativeElement, 'src', src);
  } else {
    // change background image (add the style)
    this.renderer.setAttribute(this.el.nativeElement, 'style', `background-image: url(${src})`);
  }

  // disconnect or unobserve
  observer.unobserve(entry.target);
}
Enter fullscreen mode Exit fullscreen mode

The initial value can be set directly in HTML, it is the blurry low resolution image (probably just a default sitting in assets folder), and when intersection occurs, it will be replaced. For best results, we need to establish a good starting point for the element. so that it does not change size abruptly. That can be done in css, away from the directive. It depends on your project and the context, there is no silver bullet. Knowing exactly the width and height, or even aspect-ratio is wishful thinking, none of the CSS tricks in the wild will help you in a dynamic app. But you can make educated guesses according to context.

We are using Angular Renderer2 API to manipulate the DOM to stick to their recommendation on the subject.

Lazy loading versus loading on load

I wanted to test slow loading images, that was not easy, so I just kept clearing the cache and hard reloading to get the new image every time.

In the example above, the first side effect is that when the image starts downloading, it removes the existing source, and replaces it with void. If the image is too large or the origin is too slow, it would look awful. One way to overcome this is to* download the image on the side, then replace the attribute when loaded.*

 if (entry.isIntersecting){
  // load the image on the side
  const img = new Image();

  img.addEventListener('load', () => {
    // replace and disconnect
    // ...
  });
  // start downloading
  img.src = this.crLazy;
}
Enter fullscreen mode Exit fullscreen mode

The immediate side effect of this approach is duplicate loading of the image. The second time however the browser already saved a cached version. I have tried multiple configurations, of removing cache, disabling cache in developer tools, loading random images at the bottom of the page, or on slow connection, in Chrome and in Firefox, my only conclusion (and it was hard to draw one) was that

Browsers are pretty good at caching images.

I would not worry about images loading twice, the second one is always the cached one.

Let's move on to other things.

Catch error loading images

Since we have used the onLoad event, we can also use the onError event. An error occurs when the image simply fails to load, times out, or 404's on us. That happens more often than I'd like to admit. What we need is a fallback image. Let's also add a threshold option, like we did before.

// lazy directive new options
interface IOptions {
  threshold?: number;
  fallBack?: string | null;
}

export class LazyDirective implements AfterViewInit {
  @Input() options: IOptions = { threshold: 0, fallBack: null };

  // ...

  private lazyLoad(...) {
    // when intersecting,
    if (entry.isIntersecting) {
      // create a dummy image
      const img = new Image();
      // watch onload event
      img.addEventListener('load', () => {
        // ... replace with img.src and disconnect
      });

      // watch errors to load fallback
      if (this.options.fallBack) {
        img.addEventListener('error', () => {
         // replace with this.options.fallBack and disconnect
        });
      }

      // set the source
      img.src = this.crLazy;
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

A little bit of refactoring, to bring out the image change logic on its own function, and we are ready to go. To test it, in StackBlitz multiple URLs are included. It works well.

 <picture class="card-figure">
  <img
    alt="toyota"
    [src]="placeholderImage"
    [crLazy]="serverImage"
    [options]="{ fallBack: fallBackImage }"
  />
</picture>
Enter fullscreen mode Exit fullscreen mode

Note: the fallback image itself is not pre-downloaded, this is a tool, it does not kill, but if misused it might bite. So the fallback image better be a local cached small version of a placeholder.

Setting defaults

Since we are at it, if the server image set is null (hey it happens), we should set it to the default fallback immediately.

// set defaults
ngAfterViewInit() {
  // allow null values be assuming fallBack
  if (!this.crLazy && this.options.fallBack) {
    this.crLazy = this.options.fallBack;
  }

   // ...
}
Enter fullscreen mode Exit fullscreen mode

Another easy to add feature is to set the src attribute (or initial background image) to the fallback if it exists, instead of doing that in CSS or in the IMG tag. But I do not wish to lose the feature of having a fallback on error, because I would like to show a loading image initially, then on error replace with a placeholder. Before we add this feature, let's dive into a more serious subject, server side rendering.

Server side rendering

The lower resolution of the image is a bit tricky to deal with, because sometimes, we want that image to be found by bots, so you might want to make sure the image is loaded on SSR. We cannot add the source directly and then remove it in JavaScript, because that might not be fast enough. The SSR version loads first, then hydration kicks in. You might argue, if the image is fast enough it does not need to be lazy loaded in the first place. True. With that in mind, placing the image src for server platform, then removing it for client platform, might just work. But it's not what we are here for.

Taming the loading attribute

Back out our loading="lazy" attribute. The source image is the original image to be fed to SSR and search bots as well. The enhancement we can add to this, is error handling.

// lazy loading with attribute loading only

ngAfterViewInit() {
  if (!this.crLazy && this.options.fallBack) {
    this.crLazy = this.options.fallBack;
  }

  this.el.nativeElement.addEventListener('error', () => {
    // replace image with fallback
    setImage(this.optoins.fallBack);
  });

  // no intersection observer
}
Enter fullscreen mode Exit fullscreen mode

Unfortunately, if the image is slow, we cannot replace it with a default initial value, and load in the background, because that kind of voids the idea of the loading attribute.

The complete property

I tried to replace the image on the browser platform if the image was not complete by the SSR run, but I got inconsistent results so I gave it up. We also have to observe intersections with this solution, it made no sense to me, so I am not going down this path.

// experimental code

ngAfterViewInit() {
  if (!this.platform.isBrowser && this.el.nativeElement.tagName && !this.el.nativeElement.complete){
    // replace with fallback
    setImage(this.options.fallBack);
    // then add intersection observer
  }
Enter fullscreen mode Exit fullscreen mode

Filter out bot user agents

So back to the original problem, how do we make the final image available for search bots? We can only be explicit on the server platform as to which user agents get to see the original image. Let's start with a method that returns true for a list of bots we think we want to target. (Here is a thorough list I found on the web, funny enough I can't find twitter bot there, can you?)

// check user agent for a possible bot
private isBot(agent: string): boolean {
  return /bot|googlebot|crawler|spider|robot|crawling|facebook|twitter|bing|linkedin|duckduck/i.test(agent);
}
Enter fullscreen mode Exit fullscreen mode

Then we need to inject the REQUEST token that is provided by the ngExpressEngine on SSR.

// bring in the REQUEST token
import { REQUEST } from '@nguniversal/express-engine/tokens';
import { Request } from 'express';

// inject in constructor:
// @Optional() @Inject(REQUEST) private request: Request

// on init
ngAfterViewInit() {
  // ...

  // if on server check user agent
  if (!this.isBrowser && this.request) {

    // check if it's a bot
    if(this.isBot(this.request.get('user-agent'))) {
      // load image and return
      this.setImage(this.crLazy);
      // then stop and return
      return;
    };
    // other server uses don't need to see anything
    return;
  }
  // ...
}
Enter fullscreen mode Exit fullscreen mode

With that, the image is available for the bot.

Start with initial source

Now that we got the server platform out of the way, let's go back to setting a default through code. Instead of assigning the image src or CSS background-image, we can rely on an optional feature to set it. Let's add a new option: initial. If it exists, we will immediately replace the image, so this better be a small cacheable fast loading image.

// new option for initial value

interface IOptions {
  threshold?: number;
  fallBack?: string;
  initial?: string;
}

// ...
ngAfterViewInit() {

  if (!this.platform.isBrowser && this.request) {
   // ...
   return;
  }

  if (this.options.initial) {
    this.setImage(this.options.initial);
  }
  // ...
}
Enter fullscreen mode Exit fullscreen mode

Now we can use it like this in our templates, this removes reliability on css setting the background image.

 <img
    alt="toyota"
    [crLazy]="serverImage"
    [options]="{fallBack: fallBackImage, initial: loadingImage }"
  />

   <div
      class="bg-image main-bg hm-5 h-4"
      [crLazy]="cosmeticLargeImage"
      [options]="{ initial: initialmage }"
    >
Enter fullscreen mode Exit fullscreen mode

We can also now create a loading effect for the image.

Setting a null css

Let's take it up a notch. Let's use a class that initially displays in background, or when the image fails, or when there is no image.

Side note: Adding an initial style to an image is very challenging, we cannot target :before and :after pseudo elements, and the source must be set and not broken if we are to target the background. Which quite nullifies the value of adding css. For background images however, it is very beneficial.

Say we have a hero background image at the top of the screen. Consider the following css

// example of hero waiting for image
.hero {
  background: no-repeat center center;
  background-size: cover;
  background-color: rgba(0, 0, 0, 0.35);
  background-blend-mode: overlay;
  color: #fff;
  min-height: 40dvh;
  display: flex;
  align-items: center;
  justify-content: center;
  /* image set by code */
}
Enter fullscreen mode Exit fullscreen mode

Here is how the hero looks when image succeeds to load, and when the image is null

hero image broken

So let's fix that problem without resolving to a "fall back" image. By adding a css, then upon success, removing it. In with the new option

interface IOptions {
  threshold?: number;
  fallBack?: string;
  initial?: string;
  nullCss?: string;
}

// on init, set nullCss
ngAfterViewInit() {
    if (this.options.nullCss) {
    this.renderer.addClass(this.el.nativeElement, this.options.nullCss);
  }
}

// on success, remove it
private lazyLoad(entry: IntersectionObserverEntry, observer: IntersectionObserver) {
  // ...
      img.addEventListener('load', () => {
        this.setImage(img.src);
        // success, remove extra css
        this.renderer.removeClass(this.el.nativeElement, this.options.nullCss);
        // disconnect
        observer.disconnect();
      });
}
Enter fullscreen mode Exit fullscreen mode

We can make use that in HTML and css as following

<style>
.hero-null {
  background-color: black;
}
</style>
<div class="hero" [crLazy]="null" [options]="{ nullCss: 'hero-null' }">
  A broken hero
</div>
Enter fullscreen mode Exit fullscreen mode

Now if the image is too slow, or does not load at all, we can fall back to a different style.

Herp image broken with null

Conclusion

We set out to create a directive that makes use of the IntersectionObserver API, and created two directives thus far, here are the takeaways:

  • Having a single observer on window level is a good idea, but it does not allow us to fine tuning the threshold property. The performance is not hurt dramatically if we have everything under control. If the number of elements is expected to be too many, probably a new parent directive should be created for all sub elements, as it is described in this blog post on Bennadel.com
  • We created a directive to add and remove classes to the element, and to the body, on in-view and out-of-view incidents for maximum control
  • We learned that classList.add('') fails, classList.add(null) doesn't (that was interesting)
  • We added a lazy loading mechanism for images and took care of multiple scenarios: initial images, null classes, waiting for large images to load first, handling errors of image loads, and falling back to defaults.
  • We also handled search bots for images.

A couple of loose-ends and one more use of the intersectionObserver, we'll look into it next week. Inshallah.

Thank you for reading this far, did you try it yourself? Let me know how it went for you.

RESOURCES

Top comments (0)