Chrome Image Lazy Loading - Sites Already Using it on Week 1!

Earlier this year the Chrome team announced plans to support lazy loading natively in the browser. The plan was to add a loading attribute in both <img> and <iframe> elements. Chrome 75 included it behind a feature flag so that developers could test it out. Last week, with the release of Chrome 76 this feature became generally available. I was surprised to see that it is already in use by more than 1000 sites. Lazy loading is an easy web performance win, so you may want to try this out on your sites.

Image lazy loading works by deferring the loading of images that are outside of the viewport until the user begins to scroll down the page. Historically, implementations for lazy loading involved setting the <img> src attribute to a small placeholder image and then using JavaScript to swap the image as it is needed. As a result, there have been many different implementations of lazy loading. Jeremy Wagner wrote an excellent guide on how this works along with examples and links to open source libraries.

However Chrome’s native lazy loading changes this by making it incredibly easy to configure for your site. Houssein Djirdeh, Addy Osmani and Mathias Bynens wrote about it here. Enabling it is as simple as adding the loading=”lazy” attribute to your images or iFrames:


<img src="image.png" loading="lazy" alt="…" width="200" height="200">

<iframe src="https://example.com" loading="lazy"></iframe>

When I saw the announcement that it was shipped in Chrome 76, I was curious to see how many sites have Chrome’s lazy loading attribute in production. After querying the HTTP Archive response_bodies table, I was surprised to find that more than 1000 sites already implemented the image lazy loading feature. Since this was against the July 2019 dataset, that means that these sites enabled the feature before it was available in the browser. Additionally 52 sites have implemented iFrame lazy loading. Unsurprisingly, most of the usage is loading=lazy.

Let’s step back and take a look at some of the stats around image weight and lazy loading. From a web performance perspective there are a few usual suspects slowing sites down - including but not limited to page weight, third parties, and CPU bottlenecks. I doubt this is a surprise to anyone anymore, but according to the HTTP Archive page weight has been increasing for years. We’ve known for a while that heavier sites are more likely to be slower and images are the largest source of page weight. In fact the graphs below show that both desktop and mobile home pages contain more than 70% image bytes!

Pages containing a large amount of image bytes also tend to include a large number of image assets, rather than just a few large images. For example, at the 90th percentile pages with at least 3MB of images contained over 110 image resources, and that number increases with every added MB!

It’s likely that many of the images loaded on large image heavy sites are not displayed within the viewport of a user’s browser, and only appear when a user scrolls or interacts with a page. We can confirm this with the Lighthouse audit for off-screen images. Since there is a cost to loading images, reducing their impact can help reduce bandwidth/data usage for clients as well as speed up the load times for web pages.

The graphic below shows the relationship of image weight to offscreen images, as measured by Lighthouse. With each additional MB of images, we can see a consistent increase in the amount of off-screen images. In fact, 80-90% of pages with > 3MB images are loading more than 1MB of those bytes off screen. That’s a lot of wasted bytes!

Chrome’s lazy loading feature was literally just released this week, and it’s already in use by more than 1000 sites. That’s quite impressive, and it will be interesting to track the adoption of this feature by other websites and browsers in the future.

See where your homepage sits on the table above and if you have a lot of off-screen bytes go ahead and give Chrome’s new lazy loading feature a try!

Queries:

Usage of the Lazy Loading Attribute in Chrome
Note: The response_bodies table is quite large, so I’ve saved a table containing all the lazy loading occurrences in httparchive.scratchspace.lazy_loading_2019_07_desktop if anyone would like to dig into some specific examples of pages that are using the loading attribute.

SELECT page,
       url,
       REGEXP_EXTRACT_ALL(LOWER(body), r'(?i)<img.*\sloading\s*=\s*"(lazy|eager|auto)".*\/>') img_lazy_loading,
       REGEXP_EXTRACT_ALL(LOWER(body), r'(?i)<iframe.*\sloading\s*=\s*"(lazy|eager|auto)".*\/>') iframe_lazy_loading
FROM `httparchive.response_bodies.2019_07_01_desktop` bodies
WHERE url IN (
       SELECT p.url as url
       FROM `httparchive.summary_pages.2019_07_01_desktop`   p
       INNER JOIN `httparchive.summary_requests.2019_07_01_desktop` r
       ON p.pageid = r.pageid
       WHERE  firstHtml = true
       )

Lazy Loading Summary

SELECT "img" type, loading, count(distinct page) pages
FROM `httparchive.scratchspace.lazy_loading_2019_07_desktop`
 CROSS JOIN
  UNNEST(img_lazy_loading) loading
GROUP BY loading
UNION ALL 
SELECT "iframe" type, loading, count(distinct page) pages
FROM `httparchive.scratchspace.lazy_loading_2019_07_desktop` 
 CROSS JOIN
  UNNEST(iframe_lazy_loading) loading
GROUP BY loading

Average page weight by content type

SELECT  "desktop" as desktop_mobile,
        ROUND(AVG(bytesHtml/1024),2) HTML,
        ROUND(AVG(bytesJS/1024),2) JavaScript,
        ROUND(AVG(bytesCSS/1024),2) CSS,
        ROUND(AVG(bytesImg/1024),2) Images,
        ROUND(AVG(bytesFont/1024),2) Fonts,
        ROUND(AVG((bytesFlash+bytesJson+bytesOther)/1024),2) AS Other
FROM `httparchive.summary_pages.2019_07_01_desktop` 
UNION ALL
SELECT  "mobile" as desktop_mobile,
        ROUND(AVG(bytesHtml/1024),2) HTML,
        ROUND(AVG(bytesJS/1024),2) JavaScript,
        ROUND(AVG(bytesCSS/1024),2) CSS,
        ROUND(AVG(bytesImg/1024),2) Images,
        ROUND(AVG(bytesFont/1024),2) Fonts,
        ROUND(AVG((bytesFlash+bytesJson+bytesOther)/1024),2) AS Other
FROM `httparchive.summary_pages.2019_07_01_mobile` 

Pages with the high image weights tend to have a large number of images

SELECT  "desktop" as desktop_mobile,
        ROUND(bytesImg/1024/1024) img_weight,
        count(*),
        APPROX_QUANTILES(reqImg, 100)[SAFE_ORDINAL(25)] AS pct25th,
        APPROX_QUANTILES(reqImg, 100)[SAFE_ORDINAL(75)] AS pct75th,
        APPROX_QUANTILES(reqImg, 100)[SAFE_ORDINAL(90)] AS pct90th
FROM `httparchive.summary_pages.2019_07_01_desktop`
GROUP BY img_weight
ORDER BY img_weight ASC

Lighthouse OffScreen Image Audit

SELECT ROUND(bytesImg/1024/1024) img_weight,
       ROUND(CAST(JSON_EXTRACT_SCALAR(report, "$.audits.offscreen-images.details.overallSavingsBytes")as INT64)/1024/1024) offscreenMB,
       count(*)   
FROM `httparchive.lighthouse.2019_07_01_mobile`  lh 
INNER JOIN `httparchive.summary_pages.2019_07_01_mobile`  p 
ON lh.url = p.url
GROUP BY img_weight,offscreenMB
ORDER BY img_weight,offscreenMB

Sites Using Image Lazy Loading

SELECT page, loading, count(*)
FROM `httparchive.scratchspace.lazy_loading_2019_07_desktop` 
 CROSS JOIN
  UNNEST(img_lazy_loading) loading
GROUP BY page, loading

Sites using iFrame Lazy Loading

SELECT page, loading, count(*)
FROM `httparchive.scratchspace.lazy_loading_2019_07_desktop` 
 CROSS JOIN
  UNNEST(iframe_lazy_loading) loading
GROUP BY page, loading
3 Likes

Wow this is some amazing stuff @paulcalvano. What a detailed breakdown re: image use too :heart_eyes:

I’ve also been trying to dig in and measure usage of the loading attribute. Wrote a similar query using response_bodies:

-- 17TB Query

SELECT
 "mobile" type,
 COUNT(DISTINCT CASE WHEN REGEXP_CONTAINS(body, r"\<img[^>]+loading=\"lazy\"") THEN page ELSE NULL END) AS imgLazy,
 COUNT(DISTINCT CASE WHEN REGEXP_CONTAINS(body, r"\<img[^>]+loading=\"eager\"") THEN page ELSE NULL END) AS imgEager,
 COUNT(DISTINCT CASE WHEN REGEXP_CONTAINS(body, r"\<img[^>]+loading=\"auto\"") THEN page ELSE NULL END) AS imgAuto,
 COUNT(DISTINCT CASE WHEN REGEXP_CONTAINS(body, r"\<iframe[^>]+loading=\"lazy\"") THEN page ELSE NULL END) AS iframeLazy,
 COUNT(DISTINCT CASE WHEN REGEXP_CONTAINS(body, r"\<iframe[^>]+loading=\"eager\"") THEN page ELSE NULL END) AS iframeEager,
 COUNT(DISTINCT CASE WHEN REGEXP_CONTAINS(body, r"\<iframe[^>]+loading=\"auto\"") THEN page ELSE NULL END) AS iframeAuto
FROM
  `httparchive.response_bodies.2019_07_01_mobile`
  
UNION ALL

SELECT
 "desktop" desktop,
 COUNT(DISTINCT CASE WHEN REGEXP_CONTAINS(body, r"\<img[^>]+loading=\"lazy\"") THEN page ELSE NULL END) AS imgLazy,
 COUNT(DISTINCT CASE WHEN REGEXP_CONTAINS(body, r"\<img[^>]+loading=\"eager\"") THEN page ELSE NULL END) AS imgEager,
 COUNT(DISTINCT CASE WHEN REGEXP_CONTAINS(body, r"\<img[^>]+loading=\"auto\"") THEN page ELSE NULL END) AS imgAuto,
 COUNT(DISTINCT CASE WHEN REGEXP_CONTAINS(body, r"\<iframe[^>]+loading=\"lazy\"") THEN page ELSE NULL END) AS iframeLazy,
 COUNT(DISTINCT CASE WHEN REGEXP_CONTAINS(body, r"\<iframe[^>]+loading=\"eager\"") THEN page ELSE NULL END) AS iframeEager,
 COUNT(DISTINCT CASE WHEN REGEXP_CONTAINS(body, r"\<iframe[^>]+loading=\"auto\"") THEN page ELSE NULL END) AS iframeAuto
FROM
  `httparchive.response_bodies.2019_07_01_desktop`

Seeing roughly ~2.5k distinct domains using the lazy value for images. I wonder why there’s a slight discrepancy between our numbers however :thinking:

Agree with the point that this is just for the July dataset, which was run before the feature fully launched on Chrome 76. Excited to re-run this query when the September dataset rolls out :smiling_imp:

1 Like

@housseindjirdeh one discrepancy might be case sensitivity. For example, changing this:

REGEXP_CONTAINS(body, r"\<img[^>]+loading=\"lazy\"")

to this:

REGEXP_CONTAINS(body, r"(?i)\<img[^>]+loading=\"lazy\"")

or this:

REGEXP_CONTAINS(LOWER(body), r"\<img[^>]+loading=\"lazy\"")

Also, both your and @paulcalvano’s queries assume the HTML attributes are delimited by double quotes but they could be single, double, or no quotes. A more robust regex would be:

REGEXP_CONTAINS(body, r"(?i)\<img[^>]+loading=[\"']?lazy")

An even more robust approach would be to implement this as a WPT custom metric in which we execute a query selector on the page during runtime. Generally, if you find yourself writing HTML regular expressions, chances are it’s better done in native JS :grin: That’s something we can implement now and have ready for the September crawl if interested.

2 Likes

Awesome thanks @rviscomi, definitely a more robust check accounting for both quotes + case sensitivity :slight_smile:

An even more robust approach would be to implement this as a WPT custom metric in which we execute a query selector on the page during runtime

That would be amazing! I would love to have it included as a custom metric, is there anything specific I need to do to make this happen?

All you need to do is make a PR to add a custom metric to the legacy repo.

The return value of the custom metric should probably be a JSONified array of objects, similar to the element_count metric.