Improve your website’s speed and performance by “lazy loading” images with Intersection Observer API in just a few lines of vanilla JavaScript.
“Lazy loading” is a technique that loads images or videos only when they are needed—that is, when they appear on screen. If your page contains, say, 20 images with total size of 5MB, but your user only scrolls to the third image, then it would not be necessary to request and load all 20 images immediately.
Not lazy loading your images is like ordering everything on the menu without knowing how much you’ll be able to eat. It is potentially a waste of time, resources, and—referring to the former—users’ bandwidth (which equals money for users on mobile data).
This used to be a messy effort, which involved adding/removing multiple event listeners and comparing sizes and positions. But thanks to the Intersection Observer API, which is already supported by most modern browsers, we can now implement lazy loading in under 20 lines of JavaScript!
In this post, we are building an image gallery that implements lazy loading. We’re going to do this in plain HTML, CSS, and JS—no libraries or dependencies. There are plenty lazy loading libraries and plugins (which I include at the end of this post), but this is a fun way to understand how the Intersection Observer API works and how lazy loading works at the most basic level.
You can run and “remix” the code on Glitch below.
Note: Lazy loading also works with video, but here we are focusing on images. You can find further references at the end of this post.
Implementing lazy loading
In a nutshell, these are what happens when we load our gallery page:
- The browser parses the page’s HTML and CSS, combines them to build a render tree.
- The browser calculates the space (element sizes, positions) and paints (renders) pixels to the screen. During this process, the browser requests and loads images “above the fold” normally, and loads placeholder images for the remaining images.
- The Intersection Observer API watches the lazy-loaded images. When user scrolls/tabs toward each image, it requests the actual image and swaps the placeholder with the actual image.
We are going to use the following files:
-
index.html
— HTML markup -
script.js
— the JavaScript code -
style.css
— basic CSS styles
index.html
1. Prepare the HTML markup
Let’s start with basic HTML and CSS markup. We are making a <ul>
list of cats. Each <li>
list item contains the cat’s name and image.
<ul class="cats">
<li class="cat">
<img class="cat__img" src="https://cataas.com/cat?width=300&i=1" alt="" />
<strong class="cat__name">Bustopher Jones</strong>
</li>
<!-- etc -->
</ul>
1b. Prepare the <noscript>
fallback
Now let’s duplicate each <img>
into a <noscript>
tag for images we want to lazy load. Because we want to load the first three images normally, we do this from the fifth item onwards.
<ul class="cats">
<!-- cats #1 to 4 -->
<!-- start lazy loading cat #5 onwards -->
<li class="cat">
<img class="cat__img" src="https://cataas.com/cat?width=300&i=5" alt="" />
<noscript>
<img class="cat__img" src="https://cataas.com/cat?width=300&i=5" alt="" />
</noscript>
<strong class="cat__name">Growltiger</strong>
</li>
<!-- etc -->
</ul>
🧐 Fold? What fold?
With the endless variations of device sizes, we don’t actually have “above the fold” the way we do with print. The idea is to load the first x images as usual (ie. immediately)—hence including them as our page’s “critical assets”—and lazy loading the rest as non-critical assets.
Use your own discretion to decide which images to load normally on your page!
2. Make the images .lazy
At this point, we already have regular images without lazy loading. Next, we are doing three things to each <img>
element we want to lazy load:
- Add an extra class,
.lazy
(feel free to use any name) - Replace the
src
attribute value with placeholder image - Add a
data-src
attribute with the image source value (ie. the cat image)
The placeholder image can be anything as long as it’s reasonably small. Here I use a regular 1x1px grey PNG image.
<li class="cat">
<img
class="cat__img lazy"
src="https://cdn.glitch.com/3a5b333c-942b-4088-9930-e7ea1e516118%2Fplaceholder.png?v=1560442648212"
data-src="https://cataas.com/cat?width=300&i=5"
alt=""
/>
<noscript>
<img class="cat__img" src="https://cataas.com/cat?width=300&i=5" alt="" />
</noscript>
<strong class="cat__name">Growltiger</strong>
</li>
💡 Tip: Add empty alt
attribute so screen readers do not announce the image file name.
At the bottom of the page before the closing </body>
, add the following style to hide the duplicate image for <noscript>
view if JavaScript is not supported or disabled.
<noscript>
<style>.lazy { display: none; }</style>
</noscript>
We are done with our HTML markup. Make sure we call our JS file in our <head>
element, for instance <script src="/script.js" defer></script>
, and go to the next step.
script.js
3. Prepare the function that will run Intersection Observer
The first step in our JS file is to prepare the function that will run Intersection Observer.
// Run after the HTML document has finished loading
document.addEventListener("DOMContentLoaded", function() {
// Get our lazy-loaded images
var lazyImages = [].slice.call(document.querySelectorAll("img.lazy"));
// Do this only if IntersectionObserver is supported
if ("IntersectionObserver" in window) {
// ... write the code here
}
});
What happens here:
- We add an event listener that detects when the HTML document has finished loading and parsing.
- Then we define our images to lazy load—that is,
img
elements with the class.lazy
. - To prevent error, make a conditional to run our function only if the browser supports Intersection Observer.
4. Create an Intersection Observer
Intersection Observer is a JavaScript web API that, as the name suggests, observes (watches for) when a target element intersects with (passes across) a root/container element.
An Intersection Observer consists of the following:
- target element — in this case, our images
- root/container element — defaults to viewport (we may optionally define any parent element of target, which we don’t need here)
-
callback function that is run when the target intersects with root — in this case, we swap
src
value with actual image fromdata-src
The API to create a new Intersection Observer object is new IntersectionObserver(callback, options)
.
-
callback
is a function that takes two arguments,entries
andobserver
-
options
are optional object containing options
Now we create a new observer object called lazyImageObserver
and pass it a callback function. We are not using custom options here.
if ("IntersectionObserver" in window) {
// Create new observer object
let lazyImageObserver = new IntersectionObserver(function(entries, observer) {
// ... callback function content here
});
}
5. Observe target elements
Next, we call the .observe()
method on the lazyImageObserver
we just created to watch the target elements, namely each of our .lazy
images.
if ("IntersectionObserver" in window) {
// Create new observer object
let lazyImageObserver = new IntersectionObserver(function(entries, observer) {
// ... callback function content here
});
// Loop through and observe each image
lazyImages.forEach(function(lazyImage) {
lazyImageObserver.observe(lazyImage);
});
}
6. Replace placeholder with the actual image source
Now we’re going to write the callback function that we left blank in step 4. This function is invoked when the target (each of our .lazy
images) passes the root element (ie. enters the viewport).
The callback receives an array of IntersectionObserverEntry
objects. Each IntersectionObserverEntry
object has several properties—for our purpose, we use .isIntersecting
to replace our placeholder image with the actual image source only when each image enters the viewport.
if ("IntersectionObserver" in window) {
// Create new observer object
let lazyImageObserver = new IntersectionObserver(function(entries, observer) {
// Loop through IntersectionObserverEntry objects
entries.forEach(function(entry) {
// Do these if the target intersects with the root
if (entry.isIntersecting) {
let lazyImage = entry.target;
lazyImage.src = lazyImage.dataset.src;
lazyImage.classList.remove("lazy");
lazyImageObserver.unobserve(lazyImage);
}
});
});
// Loop through and observe each image
lazyImages.forEach(function(lazyImage) {
lazyImageObserver.observe(lazyImage);
});
}
What happens when each image enters the viewport:
- Replace
src
value (placeholder image) with thedata-src
value (cat image) - Remove the
.lazy
class - Mission accomplished, stop observing this image
7. Bonus: Opera Mini “Extreme Mode” fallback
The mobile browser Opera Mini has a browsing mode called “Extreme Mode”, which uses a “proxy” server to compress and convert all data to Opera’s own format called OBML, then send it to the browser.
This mode does support JS to some extent so <noscript>
does not apply. However, our external JS file does not work here, so I use a basic fallback here to replace the placeholder images with the actual image. If a considerable percentage of your users use Opera Mini, do look into this.
<script>
(function() {
"use strict";
if ("IntersectionObserver" in window) {
} else {
// document.querySelectorAll
does not work in Opera Mini
var lazyImages = document.getElementsByClassName("lazy");
// https://stackoverflow.com/questions/3871547/js-iterating-over-result-of-getelementsbyclassname-using-array-foreach
[].forEach.call(lazyImages, function (lazyImage) {
lazyImage.src = lazyImage.dataset.src;
lazyImage.classList.remove("lazy");
lazyImage.height = 'auto';
});
}
})();
</script>
- That’s it!
Unless your images are exceptionally heavy and/or your user’s internet connection is too slow, users might not even notice the difference. 🤷🏽♀️ That said, they would most likely find your website faster and cost them less.
Results
We’re going to check the difference between the page with lazy loaded images and regular images using Chrome DevTools (similar tools are available in Firefox and Safari if you prefer), specifically the Network and Audits panels.
Network
Without lazy loading:
- 15 requests
- 284 KB transferred
- Finish (Load): 2.56 s
- HTML file size: 3.2 KB
With lazy loading:
- 7 requests ➡️ 8 requests fewer
- 172 KB transferred ➡️ 112 KB smaller
- Finish (Load): 1.92 s ➡️ 0.64 s faster
- HTML file size: 6.6 KB ➡️ 3.4 KB larger (due to more markup)
💡 Bear in mind that Load time is affected by various factors, not just the image content.
The browser makes subsequent requests for the remaining images as we scroll down.
Audits (Lighthouse)
Without lazy loading:
- Performance: 96
- Time to Interactive: 2.1 s
- First Meaningful Paint: 1.5 s
With lazy loading:
- Performance: 100
- Time to Interactive: 1.9 s
- First Meaningful Paint: 0.6 s
In conclusion, lazy loading images with the Intersection Observer API generally improves your page speed and performance, yet it makes your HTML code slightly larger. This post aims to show the most basic implementation; be sure to check out the next section for further ideas and references.
Thanks for reading!
References
- Lazy loading libraries
- Lazy loading enhancements
- IntersectionObserver polyfill — W3C
- Lazy Loading Images and Video — Jeremy Wagner
- How to use SVG as a Placeholder, and Other Image Loading Techniques — José M. Pérez
-
Native image lazy-loading for the web! — Addy Osmani
- 🚧 In development; only available in Chrome 75 and has to be enabled manually under
chrome://flags
- 🚧 In development; only available in Chrome 75 and has to be enabled manually under
- Other image optimization strategies
- Responsive images — MDN
- Optimize your images — wev.dev
- About Intersection Observer
- Intersection Observer API — MDN
- Trust is Good, Observation is Better—Intersection Observer v2 — Thomas Steiner
Top comments (9)
Great stuff but native lazy looading is right around the corner.....
Is it though?
It's behind a flag in Chrome75.. hope it will go public in 76 or 77.. works great.
Until then using InsersectionObserver as shown here is the way to go.
Once it's natively in Chrome and Firefox it's good enough /s
I have a question - say, the functionality of a button click relies on an external JS. But until the JS loads fully, the click will fail. How to handle this situation? Is there a way to disable the button until the JS loads?
Really plug and play. Create guide, thank you(:
Thank you for your article! :)
I found a little mistake:
.lazy {
display: none;
}
As the img element is not existing in the document it can not be reached with such display.
I suggest you to use instead:
.lazy {
visibility: hidden;
}
This is brilliant! Thank you! Even if the next version of chrome has lazyload as a default, a big chunk of traffic (on the site I work on) comes from safari and firefox.
That's right. We have to take into consideration all browsers, not just one!
This really does work! Thanks so much for sharing. I have to study it more deeply now!