A Complete Guide to Lazy Loading Images on the Web

clock icon Sep 20, 2019

Images make up a large percentage of the bytes sent over the web every day. Optimizing how images are loaded can improve the overall delivery and user experience on your website. Without wasting too much time, let’s get into it.

Lazy Loading

What is lazy loading? According to Wikipedia, “Lazy loading is a design pattern commonly used in computer programming to defer initialization of an object until the point at which it is needed.” In regards to images, this simply means deferring loading of an image until when the user would most likely need it. This could be when the boundaries of the image are currently in the viewport i.e. currently visible on the screen or when the user is performing an action that would require the image later.

Implement Lazy Load

There are many techniques that can be used to implement lazy loading. In this article, I’ll be exploring two of the techniques that can be used to achieve this. The first technique involves using the new loading attribute on img/iframes tags that have been introduced in chrome. The second technique involve the use of the Intersection Observer API. Let’s look at each technique in details.

Using the loading attribute

The easiest way to lazy load an image is by using the new loading attribute in Chrome. According to Google, starting with chrome 76 they will be adding native support for lazy loading of images and iframes. Lazy loading can hence be achieved like so:

<img src=”path/to/image” loading=”lazy” alt=”alt text”>

The loading attribute accepts 3 values:

  • auto - the browser determines if it should lazy load or not
  • lazy - the browser should lazy load the image
  • eager - the browser should load the immediately

While this is quite neat, it’s only available in chrome 76 and there’s no guarantee that other browsers would implement it. Hence to ensure a consistent cross-browser experience, we can add a fallback using another technique.

Intersection Observer API

The Intersection Observer API(IO) exposes a simple interface that allows web developers to “observe” elements in the DOM and get notified when an element is in the viewport i.e. in the visible region of the screen. An intersection observer can be instantiated like this:

const observer = new IntersectionObserver(callback, options);

The constructor takes two arguments: a callback function and optional options object, that can be used to specify the conditions for when the callback function is triggered. Once the constructor is created, you can observe any element in the DOM by calling the observe function:


I’ve created a repo on GitHub that I’ll be referencing so you can use it to follow along. You can also preview the demo app to see the final results.

To start with, I have a couple of img tags with a data-src attribute in my index.html. The reason is that once the src attribute has been set on an img tag, the browser immediately starts downloading the image and there is no way to stop it. At the bottom of main.js, I start the app by calling app.start. The app object looks like this:

const app = {
  start: startApp(),

start is a property whose value is a function which does some feature detection and initialization.

const startApp = () => {
  const images = document.querySelectorAll('[data-src]');
  if ('loading' in HTMLImageElement.prototype) {
    images.forEach(img => {
      img.loading = 'lazy';
  else {
    let script = document.createElement('script');
    script.async = true;
    script.src = 'intersection-observer.js';
    script.onload = () => {

The first thing done in startApp() is to get all image elements with data-src attribute. Next, we check if the loading attribute is supported by the browser. If it’s supported, we loop through the array of images, set the loading attribute to lazy on each image and then call the function loadImg(img). If the loading attribute is not supported, we load a script that detects if the browser has support for Intersection Observer(IO) but if it doesn’t, it polyfills it and then calls observeImage() passing each image in the images array.

We’ll look at loadImg() in a moment but first, let’s look at observeImage(). All this function does is create an IO with a callback isImageIntersecting() and then calls observe(image). Note: I didn’t use the optional second argument of IntersectionObserver() so the browser will use its defaults.

const observeImage = (image) => {
  const observer = new IntersectionObserver(isImageIntersecting);

The IntersectionObserver() constructor passes two arguments to isImageIntersecting(): entries and observer. entries is a list of all the elements being observed by the IO. observer is IO itself.

isImageIntersecting() is where some of the magic happens. The function looks like so:

const isImageIntersecting = (entries, observer) => {
  entries.forEach(entry => {
    if (entry.isIntersecting) {
      const img = entry.target;
      loadImage(img, observer);

Inside the function, we loop through the array of entries and check if each element is intersecting the viewport i.e. currently visible. If the element is in the viewport, we get the DOM element (entry.target) and call loadImage(img, observer) with the element and the observer as arguments.

One thing you may or may not have noticed is that loadImage() has an optional second argument: observer. By default, it’s set to null. The reason for this is loadImage() is called anytime we want to load an image, which also happens when we are not using IntersectionObserver() for lazy loading images in chrome 76+. So both of the calls below are valid:

loadImage(image, observer);

The full function looks like so:

const loadImage = (img, observer = null) => {
  img.style.opacity = 0;
  img.src = img.dataset.src;
  img.onload = () => {
    console.log('img loaded');
    img.style.opacity = 1;
    if (observer) {
  img.onerror = (error) => {
    img.style.opacity = 1;
    checkConnectivity(img, img.dataset.src);

We set the opacity of the image to 0. Then set the src attribute to the value of data-src, which immediately triggers the browser to start downloading the image. Once the image is fully downloaded, we set the opacity to 1. This is so the images have a nice fade-in effect and images only show when they are fully downloaded. 

If an observer was passed, we unobserve the image so the function does not keep getting triggered which re-downloads the image.

The function also checks if an error occurred while downloading the image and calls another function checkConnectivity(). checkConnectivity() tries to download the image again if the connectivity of the browser changes to online. It also calls another function updateConnectivityStatus() that basically updates the UI to reflect connectivity.

One thing I would add is that the window.addEventListener(‘online’) and window.addEventListener(‘offline’) do not always get triggered even when the state of connectivity changes. If you are implementing this in production, I’ll suggest you use a library that’s more robust.

To wrap up this article, one thing that’s neglected most of the time is styling broken images. What happens when an image fails to load because the location of the image has changed or it has been deleted. By default, we get this:

But we can do better. So let’s do better. In her article on styling broken images, Ire Aderinokun explained that when images fail to load, the :before and :after pseudo-selectors become available and can be used to style the appearance of broken images. Hence we can add some text to inform the user that the image failed to load like so:

img:after {
  content: “failed to load image”;

We can even make it better by using the CSS attr() to get the alt text of the image so the user gets an idea of the contents in the image.

img:after {
  content: “failed to load image of “ attr(alt);

Adding more styles to that, we get a much nicer look if images fail to load.

Thanks for reading. If you’ve enjoyed this article or found it useful, please share the article and leave a star on the GitHub repo.

Useful links