Lazy Loading Images & WebP

Next on my list of optimizations I wanted to accomplish (and eager to learn) was Lazy-loading of images and using a “modern” image format for the web.

After Materializing the site, I’ve been doing a lot of cleanup of CSS to help make the site more responsive. I referenced some W3C and Bootstrap best practices and changed as many px to rem/em as seemed reasonable (I think all that remains is the base font size and some 1px shifts for minor things). I’ve also added a scroll to top button, table of contents and a few other quality of life changes.

With the responsive design changes in CSS, it’s time to look into applying that to the images.

  • Removing unused CSS
  • Minifying/Removing FontAwesome
  • Minimizing requests to 3rd parties (fonts, metrics, etc)
  • Remove Google Fonts while still looking appealing
  • Minimizing use of JavaScript
  • Converting Images to WebP or some other optimized formats

Choosing Image Formats

The base format for images I am using is going to be png. It’s the format I’m most familiar with and with 99% of my images coming from a digital nature (ie not from a camera) it makes the most sense for me.

For the “modern” image formats there was a few options, but really only one logical choice.

FormatBrowser Support
WebP~93.5%
AVIF~63.0%
JPEG 2000~18.5%
JPEG XL0%

Maybe in the future AVIF will be more widespread, but right now WebP is the safe choice to start with.

Lazy Load Javascript

First thing to start with is the Javascript as it will help explain how I am setting up the Picture elements later to work with Lazyloading.

We will be adding loading="lazy" to our images, so we need to query all of those

const lazyImages = document.querySelectorAll('img[loading="lazy"]');

Next is the function to “load” the image. It doesn’t really load it, but instead the src is swapped and defers to the browser to do it’s thing. In the partials that are created later on, we initially have the img.src set to a blurry placeholder, so we need to swap in the real image. There is a field added to the source element (data-srcset) and one to the img element (data-src).

So the function is basically moving

  • source.data-srcset -> source.srcset
  • img.data-src -> img.src
function loadImg(img)
{
    const source = img.parentElement.querySelector('source');        
    source.srcset = source.getAttribute('data-srcset');
    img.src = img.getAttribute('data-src');
}

Next we need to check if the browser has native lazy loading support. As of writing, ~74% browsers support it.. The main browser lacking the support is Safari, but it’s apparently close to being done.

With native lazy loading, we can just instantly defer all the images to the browser itself as it will handle the lazy loading of the images for us. We just need to iterate through the images and swap the srcs to the actual images.

if ('loading' in HTMLImageElement.prototype) {
    lazyImages.forEach(img => { loadImg(img);  })
} 

For the browsers that don’t support native lazy loading, anything that is set in the src and srcset are instantly loaded. So what we want is to wait until the image is in the viewport before it loads the full sized image.

To achieve this we use the IntersectionObserver. When the image has even a pixel on the screen, isIntersecting is true and we swap the src. While it’s deferred to the browser like in native lazy loading, it is actually fully loaded.

else {
    const imgObserver = new IntersectionObserver((entries, self) => {
        entries.forEach(entry => {
            if (entry.isIntersecting) {
                self.unobserve(entry.target);
                loadImg(entry.target);
            }
        });
    });
    lazyImages.forEach(img => { imgObserver.observe(img);  })
} 

And here is the script in total

//Lazy-load
function loadImg(img)
{
    const source = img.parentElement.querySelector('source');        
    source.srcset = source.getAttribute('data-srcset');
    img.src = img.getAttribute('data-src');
}

const lazyImages = document.querySelectorAll('img[loading="lazy"]');
if ('loading' in HTMLImageElement.prototype) {
    lazyImages.forEach(img => { loadImg(img);  })
} 
else {
    const imgObserver = new IntersectionObserver((entries, self) => {
        entries.forEach(entry => {
            if (entry.isIntersecting) {
                self.unobserve(entry.target);
                loadImg(entry.target);
            }
        });
    });
    lazyImages.forEach(img => { imgObserver.observe(img);  })
} 

Making WebP and Placeholder Images

I downloaded the latest precompiled cwebp and placed in my ./bin folder of my repo (obviously feel free to place somewhere else).

I then made a script that will search all my folders for png files, ignoring favicons and placeholders.

It will then check if an existing image exists and if not create one for the following

  • Full-sized webp version of the png
  • Placeholder png (width set to 16px - height auto)
  • Placeholder webp (width set to 16px - height auto)
#!/usr/bin/env bash

var=`find . -not -path "./public/*" -not -name "apple-touch-icon*" -not -name "favicon*" -not -name "*-placeholder*" -name "*.png" | sed 's/.png//'`

for fileName in $var
do 
    echo "$fileName"
    if [ ! -f "$fileName".webp ]; then 
        ./bin/cwebp -preset picture -q 100 -m 6 -lossless -z 9 "$fileName".png -o "$fileName".webp //digital
        #./bin/cwebp -preset photo -q 100 -m 6 "$fileName".png -o "$fileName".webp //analog
    fi

    if [ ! -f "$fileName"-placeholder.png ]; then 
        convert "$fileName".png -resize 16 -quality 00 png8:"$fileName"-placeholder.png
    fi
    if [ ! -f "$fileName"-placeholder.webp ]; then 
        ./bin/cwebp "$fileName"-placeholder.png -o "$fileName"-placeholder.webp
    fi
done

I did a lot of testing and went with lossless compression on the pngs. It actually compressed the best and looks great. I am guessing it’s due to most of my images being digital screencaps. 🤷

HUGO Partials

Now that we got the images, its time to create the two partials that we will need. A normal partial and a special partial that Hugo hooks into.

render-image.html

We’ll start with the one that Hugo hooks into as it’s fairly simple. There are 3 of these that hugo uses (read more).

This allows the pretty great markdown image short code in Hugo to use the partial to render.

![Some Image Caption](image.png)

So create the file layouts/_default/_markup/render-image.html

//layouts/_default/_markup/render-image.html
<figure>
    {{ $image := .Page.Resources.GetMatch (printf "%s" (.Destination | safeURL)) }}
    {{ partial "image" (dict "path" .Destination "alt" .Text "width" $image.Width "height" $image.Height)}}
    <figcaption>
        <p class="caption">{{ .Text }}</p>
    </figcaption>
</figure>

The figure stuff can be ignored, the only things that really matter are

{{ $image := .Page.Resources.GetMatch (printf "%s" (.Destination | safeURL)) }}
{{ partial "image" (dict "path" .Destination "alt" .Text "width" $image.Width "height" $image.Height)}}

This will grab the image stats and pass them to the image partial we’re going to create next. To breakdown the options a bit

  • “path” .Destination
    • Where the Image png is
  • “alt” .Text
    • Alt text to be placed on the image
  • “width” $image.Width
    • Image Width
  • “height” $image.Height
    • Image Height

image.html

Now for the main partial file. Create a ./layouts/partials/image.html.

As talked about before, one of the things being passed in is path. We need to use this to construct all the other image paths for the images we created (webp, placeholders) in the script.

{{ $webpPath:= (replace .path ".png" ".webp")}} 
{{ $webpPlaceholderPath:= (replace .path ".png" "-placeholder.webp")}}
{{ $pngPlaceholderPath:= (replace .path ".png" "-placeholder.png")}} 

We create a source element and set the normal srcset to the placeholder image, and a custom data-srcset that points to the full-size webp.

  <source
    srcset="{{ $webpPlaceholderPath | safeURL }}" 
    data-srcset="{{ $webpPath | safeURL }}" 
    type="image/webp" >

We then create an img element and set the normal src to the placeholder image, and a custom data-src that points to the full-size png. Also set some other good practice attributes and add loading="lazy".

<img 
    src="{{ $pngPlaceholderPath | safeURL }}"
    data-src="{{ .path | safeURL }}" 
    type="image/png" 
    loading="lazy"
    alt="{{ .alt }}"
    width="{{ .width }}"
    height="{{ .height }}" >

And here is the partial in full.

<picture class="{{ .class }}">
  {{ $webpPath:= (replace .path ".png" ".webp")}} 
  {{ $webpPlaceholderPath:= (replace .path ".png" "-placeholder.webp")}}
  <source
    srcset="{{ $webpPlaceholderPath | safeURL }}" 
    data-srcset="{{ $webpPath | safeURL }}" 
    type="image/webp" >
    
  {{ $pngPlaceholderPath:= (replace .path ".png" "-placeholder.png")}} 
  <img 
    src="{{ $pngPlaceholderPath | safeURL }}"
    data-src="{{ .path | safeURL }}" 
    type="image/png" 
    alt="{{ .alt }}"
    loading="lazy"
    width="{{ .width }}"
    height="{{ .height }}" >
</picture>

Now this partial can be used around the site similar to how the render-image.html uses it. Here is a snippet of how I use it for the Avatar on the homepage.

 {{ partial "image" (dict "path" "images/avatar.png" "class" "avatar" "alt" "avatar" "width" "200" "height" "200")}}

Pretty easy.

Responsive Images

So. I wanted to do responsive images, where it serves smaller images depending on the screen size, but ran into some issues.

I have a working modified partial that handles it great. But no matter what tool I used to resize the images, the smaller (in dimension) images were either larger (in file size) than the original or had terrible artifacts.

Here is a breakdown and order of stuff I tried. For stats on these I will compare the original ~1000px width webp image (~25kb) to a target image with width of 640px.

  1. Webp resize caused terrible banding on solid colors.
  2. Added Dithering prior to Webp resize. Was still somewhat noticeable plus the file size increased (~40kb)
  3. Resized PNG using ImageMagick and then converted to Webp. Artifacts gone but Webp was large (~120kb)
  4. Resized PNG using ImageMagick, compressed png with pngquant and then converted to Webp. Better, but still large (~60kb)
  5. Resized PNG using ImageMagick, compressed png with optipng and then converted to Webp. Better, but still large (~70kb)

For each step, I tried a variety of different webp settings to reduce the size. I also tried a bunch of settings for ImageMagick and the compressors but just couldn’t get it to work.

So while I would have loved to have responsive images, I was spending too much time to get it to work. At the end of the day, I think a ~25kb for a full sized image is actually really good, so I am not going to be spending more time on it for now.

Summary

Getting the base webp structure and lazy loading working was pretty simple to do and were fun topics to read up on. These are the kind of things I love to read up on.

I didn’t know about the Picture tag before doing this and how browsers try to load webp images and have basically a fail-over if it doesn’t support webp.

Reading up on the native and all the different js lazy-loading techniques of elements was very interesting as well.

A next step for future me to look into would maybe adding AVIF support on top of this logic and maybe cropping the width of images that are too large at max size. But that’s lower on my list right now.