Chrome will soon provide native support for lazy loading images, a technique that mobile web developers have been using for years to save user bandwidth and speed up page load times by deferring the load of assets below the fold. In this post, I'll use the open source React Storefront framework as an example to walk through how to progressively enable this feature in your React app and discuss issues you need to know for server side rendering.

In an upcoming release, Chrome browser will support a new loading attribute that delays fetching iFrame and image data from the network until those assets are displayed on the user’s screen. This helps save bandwidth and improve page load times:

<img loading="lazy" src="/path/to/img.png"/>

While Chrome is leading the way, until the vast majority of mobile web traffic comes from browsers that support lazy loading images, developers would be wise to ensure their code gracefully falls back to lazy-loading via JavaScript on browsers that don’t support it.  

Polyfilling lazy loading with react-visibility-sensor

React Storefront, our open source React framework for eCommerce PWAs, has an Image component that provides lazy loading via react-visibility-sensor. The Image component also does a few other things, like switching to amp-img when rendering AMP, downscaling images for smaller screen sizes to save bandwidth, and providing fallback images for URLs that fail to load.  

For simplicity, the examples below will omit the code for those additional features to focus on how we handle lazy loading images and how you can leverage Chrome’s upcoming native support for it.

Here’s an example of how developers use the React Storefront Image component to lazy load an image with a known aspect ratio:

<Image src="/path/to/image.png" loading="lazy" aspectRatio={1} />

Here’s a simplified version of react-storefront’s Image component that illustrates how lazy loading is implemented using react-visbility-sensor:

import React, { Component } from 'react'
import VisibilitySensor from 'react-visibility-sensor'

export default class Image extends Component {
  
  constructor({ loading }) {
    super()

    this.state = {
	 // this tells us whether to render the img element or wait until it is scrolled into the viewport
      renderImage: loading != 'lazy' 
    }
  }

  render() {
    const { loading, aspectRatio, ...imgAttributes } = this.props
    const { renderImage } = this.state

    // We wrap the image in a container div and adjacent spacer that uses the provided aspect ratio to reserve space for the image so that surrounding
    // elements don’t shift around when it eventually loads.
    const wrap = (
      <div>
        <div style={{ paddingTop: `${aspectRatio * 100}%` }}/>
        { renderImage ? <img {...imgAttributes} loading={loading} /> : null }
      </div>
    )

    if (renderImage) {
      return wrap
    } else {
	 // we can discard the visibility sensor once the image is loaded
      return (
        <VisibilitySensor 
          active={!renderImage} 
          onChange={this.lazyLoad} 
          partialVisibility
        >
          {wrap}
        </VisibilitySensor>
      )
    }
  }

  // Renders the image once the container is scrolled into the viewport
  lazyLoad = (visible) => {
    if (visible) {
      this.setState({ renderImage: true })
    }
  }

}

Each image can be configured with an aspectRatio prop. This allows the component to reserve space on the page for the image before it’s loaded. This prevents the neighboring elements from shifting position when the image eventually loads, which would result in a poor user experience. Once the placeholder has scrolled into view, lazyLoad is fired, which inserts the img tag into the DOM by updating the component’s loaded state.

This implementation provides lazy-loading capability in all browsers. However, this method has some drawbacks. Every image needs its own visibility sensor instance, and a react render cycle is triggered every time the user scrolls to an image.

Using native image lazy loading when available

To get the best of both worlds, let’s update the component so it omits the visibility sensor when the browser supports the loading attribute. To do this we need to detect if the browser supports this functionality. We can do that with the following function:

/**
 * Returns true if `img` supports the loading="lazy|eager|auto" attribute.
 * @return {Boolean}
 */
function isNativeImageLazyLoadingSupported() {
  return (
    // HTMLImageElement won't be defined when rendering on the server
    typeof HTMLImageElement !== 'undefined' && 

    // feature detect in the browser
    'loading' in HTMLImageElement.prototype
  )
}

Since React Storefront framework supports server-side rendering by default, we need to make sure that our code is running in the browser before attempting to detect the feature.

Next, update the initial value of the renderImage state, which determines whether or not the img element is rendered:

  constructor({ loading }) {
    super()

    this.state = {
      renderImage: loading != 'lazy' || isNativeImageLazyLoadingSupported()
    }
  }

Finally, add the loading prop to the img element so that the browser knows to lazy load the image:

<img {...imgAttributes} loading={loading} />

A Call to Action: Vote for lazy load client hint

Lazy loading image support will definitely improve website performance when rendering in Chrome, but it still has limitations. Since the server doesn’t know if the browser supports native lazy loading, we can’t render the img tags on the server during the Server Side Rendering (SSR) process, which is critical to improving website speed. Instead, images can only be inserted into the DOM when the react app mounts, which will noticeably slow down perceived page speed, especially on slow connections where the JavaScript bundles might take longer to download.

There is a proposed HTTP header that would allow servers to know if lazy-loading is natively supported by the user’s browser. This would allow the server to decide if it should return img elements during server side rendering rather than placeholders when the browser natively supports lazy loading.  

Theoretically, you can do this by parsing the user-agent, but it’s widely recognized that feature detection is a more reliable and maintainable method than targeting specific browser versions. There’s another issue with changing the rendered output based on user-agent: caching. Most CDNs control caching through the vary header, which identifies which headers to use when computing the cache key. Varying based on user-agent is a great way to kill your cache hit ratios by fragmenting your cache.  

Providing a specific header for this functionality would only fragment the cache by a factor of two, and given Chrome’s enormous user base, there will be enough traffic to ensure well-populated caches days after this feature is released. If you agree, I ask you to post your support for the lazy load client hint in this ticket.

Solutions for Server Side Rendering (SSR)

If you’ve been tasked with delivering a performant react site that usually means you need server-side rendering. While lazy load client hint will be a great solution in the future once (and if) browsers support it, you need a performant solution today for today’s existing browsers. There’s a couple of workarounds in the meantime.

One option is to just not render the images server side and let them populate client side when the react app mounts. For simplicity, this was the option I’ve shown in the examples above.

Another option is to only use lazy loading for images “below the fold,” i.e. only set the lazy loading prop for images that you know will be off screen. This is often easier said than done, especially in a responsive site where you don’t know where the “fold” will exist. However, you may find heuristics that can help. For example, on an eCommerce category page you might turn off lazy loading for the first set of product images knowing they are likely to be “above the fold.” And for items you know will consistently be above the fold (e.g. headers, company logos, cart icons) you shouldn’t enable lazy load anyway and can always render server side.

A final option is to use user-agent detection to serve an appropriate version of the server-side rendered page. As we said earlier in this article, user-agent detection is not recommended for progressive enhancement, but sometimes it’s the only option while you wait for browsers and standards bodies to catch up. This does, however, require normalizing your cache keys appropriately otherwise you could fragment your cache significantly.

In a future post, we'll show how you can take the pain out of normalizing your cache and server-side rendering via the Moovweb XDN's edge side logic (ESL). And those same techniques can be used to emulate support for lazy load client hint even though it doesn’t exist yet. Stay tuned!