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)
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}" />
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.
anchorSetup
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);
}
}
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>
anchorSet 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);
}
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.
anchorLazy 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;
}
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.
anchorCatch 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;
}
}
}
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>
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.
anchorSetting 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;
}
// ...
}
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.
anchorServer 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.
anchorTaming 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
}
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
}
anchorFilter 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);
}
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;
}
// ...
}
With that, the image is available for the bot.
anchorStart 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);
}
// ...
}
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 }"
>
We can also now create a loading effect for the image.
anchorSetting 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.
: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 */
}
Here is how the hero looks when image succeeds to load, and when the image is null
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();
});
}
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>
Now if the image is too slow, or does not load at all, we can fall back to a different style.
anchorConclusion
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 thethreshold
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.