Previous

How Improving PageSpeed Can Increase Organic Traffic.

Next

Houwzer


Lazy Loading Images with CraftCMS

Using CraftCMS's Templates and JavaScript to lazy load in images and improve page performance.

Images are some of the most important things on a website. They also have a tendency to cause the biggest problems. The biggest issue I always have with images is size. Large images take time to download and can prevent the browser from delivering information that is probably more important.

That’s where lazy loading comes in. The idea is to only load in high resolution images as they’re necessary, and have a low resolution placeholder when they’re of screen. The placeholder prevents the page from skipping around and gives the user the sense that something's coming.

There are a few different ways to solve this problem, but the solution I’m going to go over involves using Craft’s image transforms with a bit of JavaScript to load up a low quality image then replace it with a high quality one later.

To start, we’re going to look at this example which is a page with 10 images of varying sizes arranged with some CSS grid voodoo. This first example is loading the images normally, without any kind of preloading enabled.

Site Note: Chrome behaves a little weird with `display: grid`. Firefox will play nicely if you don’t set the rows, but Chrome won’t. Chrome also doesn’t play nicely if you don’t set the correct number of rows or columns, nor does it like the `fr` measurement without the use of `minmax()`.

.grid {
  display:grid;
  grid-template-columns:repeat(3, minmax(250px, 1fr));
  grid-template-rows: repeat(5,33vh);
  grid-gap:20px;
}

You should never trust that an image uploaded by a client is going to be optimized. It’s an easy thing for designers and developers to forget to do, so don’t expect a client to remember. If we take a look at the first load, without any kind of modifications to the images, we’re sitting at 25.77MB of data transferred. This is typically what we can expect from most client images.

This first example, we just include the default unmodified images.

<div class="grid">
  <div class="cell">
    <img src="{{ image.getUrl() }}" />
  </div>
</div>

With this example, our initial data transfer is pretty high for only serving up images, and our Lighthouse score reflects that with a low score.

Unoptimized loadtime. Inital Google Lighthouse score.

The first step to improve the score is to head into our CraftCMS admin and create a new image transform. Transforms will allow Craft to create and serve up new versions of the image based on the parameters you set. For this, we’re going to create two versions: one will be our full size image and another will be our preloaded image.

You can get to the transforms under the Settings > Assets section of Craft. There are a few settings and values to set here.

  • Name: This is what you’ll pass into the template to call this version of the image
  • Handle: Gets generated from the name, but you can change it if you want
  • Mode: You can pick how you want the image to adjust. If you’re using crop you can adjust where the (0,0) is for resizing the image
  • Width: You have to set width or height unfortunately. Personally, I vote for width, because you can use `object-fit: cover` to deal with the height if needed. The restraining property on an image is also typically the width.
  • Height: If you chose width and aren’t cropping leave this blank, otherwise you’re going to skew the image.
  • Quality: Adjusts the image quality, similar to if you were to save it out in PhotoShop with a quality of 90.
  • Interlacing: This affects how an image is shown when a part of it is received.

 

Once the two transforms are completed, you can call them by passing the handle into the `.getUrl()` function. You can see the difference here.

<div class="grid">
  <div class="cell">
    <img src="{{ image.getUrl('full') }}" />
 </div>
</div>

This alone already caused a pretty drastic change in performance. The page is down from 25MB to 2.21MB, and there's a pretty good increase in the Lighthouse score too.

Optimizing Images with Craft Transform Lighthouse Score with Optimized Images
No Changes Optimizing Images
Transfer Size 25.77 MB 2.21 MB
Lighthouse Score 36 58
Image Sizes 4,780 kB / 56,530 ms 1,060 kB / 8,650 ms
Offscreen Images 3,022 kB / 35,740 ms 1,455 kB / 6,320 ms
Optimize Images 118 kB / 1,390 ms Pass

Now, let's look at how we can lazy load the images in to improve the score further. You can view this version of the example here.

The first thing to do is change up the image tag. We’re going to load in the pre-image transform we created earlier, then swap in the full image using JavaScript when the image comes into view.

<div class="grid">
  {% for image in entry.gallery.all() %}
    <div class="cell">
      {% if preload %}
        <img class="lazy" style="background:center / cover no-repeat url({{ image.getUrl('pre') }});"
          src="{{ image.getUrl('pre')}}"
          data-src="{{ image.getUrl('full') }}" />
      {% else %}
        <img src="{{ image.getUrl('full') }}" />
      {% endif %}
    </div>
  {% endfor %}
</div>

We’re adding a `lazy` class, so our JavaScript knows to target that image. You can exclude this if you want an image to load properly from the beginning. Next, I’m setting an optional `background-image`. This prevents a white screen skip from happening when the JavaScript goes to load in the new image. Next up, we set the `data-src` to be the url to our full image.

Once we have all of that in place, you can see what it does to the transfer size. Because we’re serving up a 100px wide, low quality image if it’s not on screen, our initial transfer size is only 15.14 kB.

Load time of lazy loaded images.

The last step is to include the JavaScript that will load in our images.

document.addEventListener("DOMContentLoaded", function() {
  var lazyImages = [].slice.call(document.querySelectorAll("img.lazy"));

  if ("IntersectionObserver" in window) {
    let lazyImageObserver = new IntersectionObserver(function(entries, observer) {
      entries.forEach(function(entry) {
        if (entry.isIntersecting) {
          let lazyImage = entry.target;
          lazyImage.src = lazyImage.dataset.src;
          lazyImage.classList.remove("lazy");
          lazyImageObserver.unobserve(lazyImage);
        }
      });
    });

    lazyImages.forEach(function(lazyImage) {
      lazyImageObserver.observe(lazyImage);
    });
  } else {
    // Possibly fall back to a more compatible method here
  }
});

This script is looking for the Intersection Observer, which will check if the images is in view without having to use event listeners. Sadly, this isn’t supported in Edge 15 yet, so you should probably have a fall back for that. You can check out Google’s web fundamentals for more information about how this script works in this article by Jeremy Wagner.

Now that we’re lazy loading our images in, we can check our Lighthouse Performance score one more time.

No Changes Optimizing Images Lazy Loading
Transfer Size 25.77 MB 2.21 MB 15.14 kB
Lighthouse Score 36 58 75
Image Sizes 4,780 kB / 56,530 ms 1,060 kB / 8,650 ms Pass
Offscreen Images 3,022 kB / 35,740 ms 1,455 kB / 6,320 ms Pass
Optimize Images 118 kB / 1,390 ms Pass Pass

The score failed on Perceptual Speed index, and JavaScript boot-up because the script isn’t minified which is keeping it out of the 80s or 90s

Overall, there are quite a few benefits to doing this, the first being that increasing your PageSpeed Insights score can increase your organic search traffic. The other important thing is that you aren’t forcing a user to load large images that they may never see. Because we’re only serving up small 1kB images until they scroll into view, there's less wasted data transfer that goes on.

This technique should work with or without the use of Craft, you would just have to create the preload images yourself.