Using IntersectionObserver to lazy load Disqus comments

By Caroline Liu

Disqus is the plugin I’m currently using on this site for post comments. (You’ll see it if you scroll to the very bottom of the page!) It’s super convenient to set up: Sign up on Disqus.com, set up your site, and insert the following code on your post pages to automatically add a Comments section:

<!-- Disqus comments will be inserted here once loaded -->
<div id="disqus_thread"></div>

<script>
// Set up a global function that Disqus script can use when it's loaded
var disqus_config = function () {
this.page.url = '<POST_CANONICAL_URL>';
this.page.identifier = '<POST_ID>';
};

// Fetch Disqus script, which will load a comments iframe on this page
(function() {
var d = document, s = d.createElement('script');
s.src = 'https://<SITE_ID>.disqus.com/embed.js';
s.setAttribute('data-timestamp', +new Date());
(d.head || d.body).appendChild(s);
})();
</script>

They give this code to you, so you don’t need to write any code to install Disqus.

This is how I’ve used Disqus for years, but I’ve never really been happy with this setup, for several reasons:

  1. It prevents me from setting up a secure content security policy (CSP). I want to set up a CSP that blocks inline scripts from running on my site, so that if a third-party injects malicious code into my site, it would just fail to execute. However, I can’t set up such a policy if I need to run Disqus’ inline script!
  2. It slows down my initial page load. Sure, the inline script looks innocuous – it just fetches one embed.js script, right? Well, that one script fetches a whole slew of other scripts and assets! On top of that, this content is not needed until the user has scrolled all the way to the comments section anyway.
  3. Inline scripts are ugly. My HTML is gorgeous and this inline script is an eyesore. Please, it has to go.

In this post, I’ll illustrate how to get rid of the inline script and lazy load Disqus.

Loading Disqus without an inline script

First, move the contents of the inline script to a function in a JS file:

/* setup.js */

function loadDisqus() {
var disqus_config = function() {
this.page.url = '???';
this.page.identifier = '???';
};

// Note that I cleaned up the variables here for personal sanity.
const scriptEl = document.createElement('script');
// Remember to set your own SITE_ID here.
scriptEl.src = 'https://<SITE_ID>.disqus.com/embed.js';
scriptEl.setAttribute('data-timestamp', +new Date());
(document.head || document.body).appendChild(scriptEl);
}

Next, add an event listener to call this function after the HTML content has loaded:

/* setup.js */

function loadDisqus() { /* ... */ }

window.addEventListener('DOMContentLoaded', function() {
loadDisqus();
});

Add a script tag to your HTML page to reference this JS file, then refresh the page. Your browser dev tools should show you that a ton of Disqus resources were fetched, as before. However, if this was a page where you had a non-zero* number of comments, you’ll notice that you now have no comments. (I say this because if you are testing with a page with no comments, which I sometimes did, you might be tricked into thinking everything is working normally because the Disqus UI still shows up. It’s a lie, my friend. Test on a page with comments.) Disqus failed to fetch our comments because it couldn’t find the global disqus_config function.

The disqus_config variable in our loadDisqus function is locally scoped and therefore cannot be accessed outside of this function. To make it globally available, we attach it to window. I don’t like global variables as much as the next developer, but sometimes you gotta do what you gotta do.

function loadDisqus() {
// Attach this function to `window` to make it globally available.
window.disqus_config = function() {
this.page.url = '???';
this.page.identifier = '???';
};

/* ... */
}

Finally, we have to figure out how to pass the page URL and page id to our function. On my site, these values were previously templated right into the inline script, as all my pages are generated with Nunjucks templates. Now, I can template the values into data-* attributes instead:

<body data-disqus-page-url="{{ postUrl }}" data-disqus-page-id="{{ postId }}">
...
</body>

(You can set the data attributes on another element on your page, if that works better for you. I’m using <body> so I can present the general concept of this change, without getting bogged down by DOM traversal syntax.)

Next, modify loadDisqus to pull these attribute values:

function loadDisqus() {
window.disqus_config = function() {
// `disqus_config` is only ever called once by Disqus, so each
// of these `getAttribute` calls will only be called once.
this.page.url = document.body.getAttribute('data-disqus-page-url');
this.page.identifier = document.body.getAttribute('data-disqus-page-id');
};

/* ... */
}

At this point, if you refresh your page, your comments should be loading again, no inline script required! 🎉

Here is the full solution, thus far:

setup.js

function loadDisqus() {
window.disqus_config = function() {
this.page.url = document.body.getAttribute('data-disqus-page-url');
this.page.identifier = document.body.getAttribute('data-disqus-page-id');
};

const scriptEl = document.createElement('script');
scriptEl.src = 'https://<SITE_ID>.disqus.com/embed.js';
scriptEl.setAttribute('data-timestamp', +new Date());
(document.head || document.body).appendChild(scriptEl);
}

window.addEventListener('DOMContentLoaded', function() {
loadDisqus();
});

HTML post template

<body data-disqus-page-url="{{ postUrl }}" data-disqus-page-id="{{ postId }}">
<!-- Post content -->

<!-- Disqus comments will be inserted here once loaded -->
<div id="disqus_thread"></div>

<!-- Our own script, that loads the Disqus code -->
<script src="setup.js"></script>
</body>

Loading Disqus only when it’s time to see it

So we’ve gotten rid of the inline script. Our HTML is beautiful and we can set up that CSP. However, if we can also delay loading Disqus until our readers have scrolled to the comments section of the page, we’d gain the following benefits:

  1. Faster initial page load: Less waiting for your readers!
  2. Potential data savings: If your reader never scrolls all the way to the comment section 😢, Disqus never loads 🎉! (A bittersweet moment, for sure.)
  3. Better Lighthouse score: One step closer to that perfect 100 score!

Implementing something like this a few years ago would’ve been fairly complicated. It would involve scroll events and lots of measuring, and the performance hit of this work might eclipse any benefit we gain from lazy loading Disqus.

However, in 2021, we have IntersectionObserver, a built-in Web API that is supported by all major browsers. (Except IE 11 of course, but we’ll talk about that later.)

IntersectionObserver allows us to monitor a set of DOM elements and do things whenever those elements come into view. For our purposes, we just want to monitor the comments area, and load Disqus when it comes into view.

So, let’s add a new function called lazyLoadDisqus. This function creates an instance of IntersectionObserver and uses it to monitor the DOM element #disqus_thread, where our comments will eventually be inserted. When #disqus_thread comes into view, we’ll call loadDisqus. Also, we’ll update our event listener so it calls lazyLoadDisqus on page load instead of loadDisqus.

/* setup.js */

function loadDisqus { /* ... */ }

function lazyLoadDisqus() {
const observer = new IntersectionObserver(function(entries) {
// `entries` is an array of all the elements being monitored. In our
// case, it's always an array of one item. I'm still using a loop here
// though since it's an easy way to handle edge cases (e.g. if the
// #disqus_thread element was missing).
for (const entry of entries) {
if (entry.isIntersecting) {
// Comments area is in view; let's load Disqus!
loadDisqus();
}
}
});

// Monitor just one element, #disqus_thread.
// If you had to monitor multiple elements, you'd have to call `observe`
// on each of those elements.
observer.observe(document.getElementById('disqus_thread'));
}

window.addEventListener('DOMContentLoaded', function() {
// Call the lazyLoadDisqus function instead, which sets up an
// IntersectionObserver instead of immediately loading Disqus.
lazyLoadDisqus();
});

This works great at first. The Disqus scripts and assets are not fetched on page load anymore, but instead, are fetched when you scroll all the way down to the comments area! I feel so high tech.

But there is a problem, and the problem is that if you scroll back up, and then back down, Disqus gets loaded again. 😱 This is because the observer is still watching the #disqus_thread element, and every time it scrolls into the viewport, it calls loadDisqus! This is very bad, but also very easy to fix: Just stop monitoring #disqus_thread after its first appearance!

function lazyLoadDisqus() {
const observer = new IntersectionObserver(function(entries) {
for (const entry of entries) {
if (entry.isIntersecting) {
loadDisqus();

// We saw this element once, and that is enough. Stop monitoring it.
// (`entry.target` is a reference to the DOM element.)
observer.unobserve(entry.target);
}
}
});

observer.observe(document.getElementById('disqus_thread'));
}

Now, no matter how many times you scroll #disqus_thread in and out of view, loadDisqus will only be executed once. 🎉

Legacy browser support

Finally, there is IE 11 and older browsers to consider. We don’t want to remove the entire commenting experience for users of these browsers. So, we’ll add a feature check to lazyLoadDisqus: If the browser has support for IntersectionObserver, we’ll use it to lazy load Disqus. Otherwise, we’ll load Disqus immediately.

function lazyLoadDisqus() {
if (!('IntersectionObserver' in window &&
'IntersectionObserverEntry' in window &&
'isIntersecting' in window.IntersectionObserverEntry.prototype)) {
// This browser does not support IntersectionObserver.
// Load Disqus immediately.
loadDisqus();

} else {
// This browser supports IntersectionObserver.
// Load Disqus later.
const observer = new IntersectionObserver(function(entries) {
/* ... */
});
observer.observe(document.getElementById('disqus_thread'));
}
}

The feature check is fairly long, because some browser versions have a partial implementation of IntersectionObserver. That’s why it’s not sufficient to only check if window.IntersectionObserver exists; we must check that all the features of IntersectionObserver that we need are implemented by the browser.

But that is it! We have successfully set up lazy loading for our comments! 🎉

To recap, here is the full solution:

setup.js

function loadDisqus() {
window.disqus_config = function() {
this.page.url = document.body.getAttribute('data-disqus-page-url');
this.page.identifier = document.body.getAttribute('data-disqus-page-id');
};

const scriptEl = document.createElement('script');
scriptEl.src = 'https://<SITE_ID>.disqus.com/embed.js';
scriptEl.setAttribute('data-timestamp', +new Date());
(document.head || document.body).appendChild(scriptEl);
}

function lazyLoadDisqus() {
if (!('IntersectionObserver' in window &&
'IntersectionObserverEntry' in window &&
'isIntersecting' in window.IntersectionObserverEntry.prototype)) {
loadDisqus();
} else {
const observer = new IntersectionObserver(function(entries) {
for (const entry of entries) {
if (entry.isIntersecting) {
loadDisqus();
observer.unobserve(entry.target);
}
}
});
observer.observe(document.getElementById('disqus_thread'));
}
}

window.addEventListener('DOMContentLoaded', function() {
lazyLoadDisqus();
});

HTML post template

<body data-disqus-page-url="{{ postUrl }}" data-disqus-page-id="{{ postId }}"> 
<!-- Post content -->

<!-- I added placeholder text here, so that if the user is on a slow network,
they get an indication that this area is supposed to contain comments, and
that the comments are coming soon! Disqus will replace this text with comments
without further work on our part. -->

<div id="disqus_thread"> Fetching comments... </div>

<!-- Our script -->
<script src="setup.js"></script>
</body>

Conclusion

This solution gets us pretty far, but of course, there are some edge cases it doesn’t cover. For example, if the user coincidentally loses their internet connection when we’re attempting to load Disqus, there is no automatic retry, and there is no way for the user to trigger a manual retry either. That’s a separate battle altogether, and one that’s not necessary for me at the moment. I’m just happy that I solved all my personal pain points with Disqus that I listed at the beginning! 🌈

Now, time to get cracking on that content security policy… 👀

Fetching comments...