Lazyload Elements with JavaScript Intersection Observer API

Photo by insung yoon on Unsplash

So not long ago, I came across an use case of lazyloading images using npm Lozad.js module (Check it out if you haven’t — it is a small but powerful little thing). Well, convenient as it is, I’m keen to understand the mechanism behind it to unleash its full potential, and so I learnt about Intersection Observer API (I know it’s been out for a while and sorry for the late).

The Intersection Observer API provides a way to asynchronously observe changes in the intersection of a target element with an ancestor element or with a top-level document’s viewport. — -MDN

The above statement summarises its usage. But what is it needed for? Well, previously when we want to detect if a DOM element enters/leaves viewport so we can do things like lazyloading, etc., we need to make use of scroll events and potentially calling getBoundingClientRect(). This can be a performant throttle as calling getBoundingClientRect() runs on the main thread and forces the browser to re-layout the entire page and scroll event listeners can fire too many times that will cause the browsing activities sluggish. Not to mention it’s nearly impossible to detect an element’s entering and leaving inside another element, ie. an iframe.

But the Intersection Observer (IO) API solves this by registering a callback function that is executed whenever an element they wish to monitor enters or exits another element (or the viewport) by a requested amount. So the main thread can be free instead of getting buzzed constantly. This is due to the Observer’s nature. Contrary to Event, which queues and fires synchronously and completes, an Observer “observes” and reacts asynchronously.

Let's run a postmortem of the OI API.

var observer = new IntersectionObserver(callback, options);

The IntersectionObserver object’s constructor takes two parameters. The first one is a callback function that gets fired when an observation point gets triggered. The second optional parameter is options, which is an object with configs about “intersection.”


Options has three properties:

  • root – The ancestor element/viewport that the observed element will intersect. This defaults to viewpoint if unset or the ancestor of the observed element.
  • rootMargin – Margin around the root, shrinking or growing the root element’s area to watch out for intersection. It’s similar to the CSS margin property.
  • threshold – An array of values (between 0 and 1.0), each value represents the percentage of such intersection at which the Observer should react, ie. when the callback is to be triggered. Defaults to 0, meaning the callback will run as soon as the element is in view. For an array like threshold: [0, 0.2, 0.5, 1] it means that the observer should trigger in 4 cases each at 0, 20%, 50% and 100% intersection ratio.

You can pass in the options like:

const config = {
root: null,
rootMargin: '0 200px 0 0',
threshold: [0, 0.5]
let observer = new IntersectionObserver(function(entries) {

}, config);

Note that the callback will be fired not only when the observed element intersects with the root element, but also when the first time the observer is initially asked to watch a observed element.


The callback function takes two arguments, the first is an Array of IntersectionObserverEntrywith a list of predefined properties regarding the observed elements, and the second is the Observer itself.

cb = function(observerEntries, self) {…};

For properties defined on IntersectionObserverEntry:

  • rootBounds: A bigger rectangle area for the observed element(root + rootMargin);
  • boundingClientRect: A rectangle for the observed element ;
  • intersectionRect: An rectangle for the intersected area;
  • target : The observed element;
  • time : A TimeStamp indicating the time at which the intersection was recorded, relative to the IntersectionObserver's time origin.
  • rootBounds : The root element;
  • isIntersecting : A Boolean value which is true if the target element intersects with the intersection observer's root.
  • intersectionRatio The value is a float number indicates how much of the observed element’s area is intersecting with the root element (the ratio of intersectionRect area to boundingClientRect area).

All these properties are available in the callback through entries:

let callback = (entries, observer) => {
entries.forEach(entry => {
// Each entry describes an intersection change for one observed
// target element:
// entry.boundingClientRect
// entry.intersectionRatio
// entry.intersectionRect
// entry.isIntersecting
// entry.rootBounds
// entry.time

Be aware that your callback is executed on the main thread. It should operate as quickly as possible; if anything time-consuming needs to be done, use Window.requestIdleCallback().

Also, note that since we need a box-like object wrapping the observed element, it means certain rules have to be obeyed:

  • Elements with display: none cannot be detected;
  • Elements with visibility:hidden can be detected;
  • Absolutely positioned elements with width:0px; height:0px can be detected but not if it is completely outside of parent’s borders with negative margins e.g. or cut out by parent’s overflow: hidden .


So enough of the theory, let’s look at an example.

First register an element/elements for the Observer to watch. In our example, we will try lazyloading some images:

const images = document.querySelectorAll('[data-src]');
<img src=”placeholder.png” data-src=”img-1.jpg”>
<img src=”placeholder.png” data-src=”img-2.jpg”>
<img src=”placeholder.png” data-src=”img-3.jpg”>

The reason we put a data-src is to prevent the image gets loaded when the page gets rendered, instead we let it render a blank (and efficient) placeholder for now.

Next, create the callback function to be called when the image lazy loads:

let observer = new IntersectionObserver((entries, observer) => { 
entries.forEach(entry => {
console.log("start to intersect"); =;
}, options);
const options = { rootMargin: '0px 0px 100px 0px' };

Note we use entry.isIntersecting to detect if the images go into viewpoint. Once it enters into viewpoint, we assign the true src to its placeholder src , and then we unobserve the element after performing the actions on the observed element aka the lazyloading. This is a necessary clean-up step like you do with element.removeEventListener() to prevent memory leaks.

Remember to pass in the options object if you need, in our case we set the bottom margin to 100px. This means the picture will start to be intersecting 100px from bottom instead 0px from the bottom. We add this because we want to pre-load the picture instead of only starting to load it when it comes into view.

Next, register the elements to be observed with the observer.

let observer = new IntersectionObserver((entries, observer) => {
entries.forEach(entry => {
console.log(entry); =;
images.forEach(image => { observer.observe(image);

Infinite scrolling?

Another example is infinite scroll — whenever the scrollerFooter at the end of the page gets scrolled into view, we know it’s the end of the page so load another 10 items.

var intersectionObserver = new IntersectionObserver(   
function (entries) {
if (entries[0].intersectionRatio <= 0)
loadItems(10);//the customised function to load items
console.log('Loaded new items');

Auto-pausing videos?

Another use case is to defer the video/ads playing or auto-pause videos/ads when it is out of view, otherwise it would be annoying to hear the sound playing but missing the content.

let video = document.querySelector('video');
let isLeaving = false;
let observer = new IntersectionObserver((entries, observer) => {
entries.forEach(entry => {
if(entry.intersectionRatio!=1 && !video.paused){
isLeaving = true;
else if(isLeaving) {;

}, {threshold: 1});
observer.observe(video) ;

Note that we have threshold set to 1, so we will only trigger callback function when the video is 100% displayed. And immediately when it’s not 100% displayed we will pause it. We use `isLeaving as a reminder as to whether the video is paused or not.

Final remarks

So we can see InteresectionObserver is very useful as an async API in observing DOM elements coming in and out of view (viewpoint or other root elements) and performing a callback task to handle the element. It provides many granular properties on the elements being observed that is convenient for us to manipulate.

However, be aware the callback itself is a synchronous function. If you do perform something really heavy for an element in view, try to use requestidlecallback discussed here.

Also, if you are lazy loading assets, remember to unobserve or even disconnect(if you only need it once)after the asset has been loaded.

And finally, if you are interested in other Observers, please check below:

That’s so much of it! Happy Reading!




Hi :)

Love podcasts or audiobooks? Learn on the go with our new app.

Recommended from Medium

9 Undisputed Pieces of Programming Advice If You Want to Develop Good Code

React internationalization i18n with react-intl

Weekly Updates

How to Find Unique Strings in an Array Using JavaScript

Two try-catch caused dive deep into JavaScript asynchronous programming

Starter Pack Will Change How Developers Build Apps

12 Javascript Array Method Every Javascript Developer Should Know

(Why) Is jQuery dead?

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store


Hi :)

More from Medium

Learn JavaScript Testing

Recursive JavaScript method to process multiple directories drops in a Drag-n-drop upload operation

TypeScript Tutorial for Beginners to Advanced — Introduction

Javascript — Tricky Questions-Array