Polyfilling the Intersection Observer API the right way

The Intersection Observer API has great support across browsers, but it's advised to use its polyfill if Internet Explorer support is required.

Polyfilling the Intersection Observer API the right way

Generally speaking, adding a polyfill is as simple as adding a script tag (without a defer or async attribute) that will download the file with the lines of code that perform the needed checks and if the feature doesn't exist, the polyfill kicks in.

The Usual way to polyfill the Intersection Observer API

Polyfilling the Intersection Observer API is not any different than other cases. Specifically speaking about the Intersection Observer API, its polyfill is needed if Internet Explorer needs support.

The popular approach to make this happen would be to add the following script tag to the list of scripts in the HTML:

<script src="https://www.gstatic.com/external_hosted/intersectionobserver_polyfill/intersection-observer.min.js"></script>

Actually, there's an extra step I didn't mention. This script tag is not only needed in the HTML, but it needs to be positioned strategically before the script tag that could/would use it. In other words:

<script src="https://www.gstatic.com/external_hosted/intersectionobserver_polyfill/intersection-observer.min.js"></script>
<script src="/main.min.js"></script>

This is the most straightforward approach to do a polyfill. And it's fair to go down this path if you're looking to put something together real quick, or maybe if web performance is not on your top priority (but why?). Having said that, I'd like to dig a bit deeper to show some of the trade-offs of this approach and propose a better and more performant way to do the polyfill.

🙅‍♀️ The cons of the usual approach

The main gap is Web Performance, given that it directly impacts loading times.

As mentioned above, this script tag would need to exist in your HTML and as the tag wouldn't have a defer or async attribute, it would become an HTML render-blocking resource as the browser would need to stop, fetch the file, wait for the file to be downloaded, then parse/compile it and finally execute it, being the download and execution the most costly steps. Now let's imagine the user is visiting the page from a slow network with a low-tier device, yikes!

To sum up, here are a handful of cons of going with the straightforward script tag:

  • ⏳ The file would need to be downloaded, which adds extra loading time to the HTML rendering process
  • ⏳ The file would need to be executed, which adds extra time to the HTML rendering process
  • All of the above will always happen even if the polyfill is not needed. In other words the user would need to wait even if the browser that's being used provides native support to the API
  • The main JS file–below this script tag–needs to always wait for the polyfill to be downloaded, parsed/compiled and executed, even though the Intersection Observer might not be needed until later

There might be more items to this list, please add a comment if you've identified more. I'll try to keep these updated.

✅ The recommended approach

Having Web Performance in mind and aiming to find efficiencies wherever possible, the recommendation would be to remove the hardcoded script tag and do a sanity check that dynamically adds it. This would mean that the main JS source code will do a light/quick check to then decide if the polyfill file request is really needed. Which means that the extra loading time won't be added for the majority of users visiting the site (IE has 2.15% of user share when this post was written).

So let's dive right in and explore how to make this happen.

⛏ The helper

To start off, a helper function is needed to check if the Intersection Observer API exists:

const isIntersectionObserverSupported = (global) =>
  'IntersectionObserver' in global &&
  'IntersectionObserverEntry' in global &&
  'intersectionRatio' in global.IntersectionObserverEntry.prototype;

This function will tell us if the IntersectionObserver is supported by the browser running the script. The output will help us decide if the polyfill needs to be dynamically fetched or not.

🪄 The core

We can now create the core function responsible of doing the polyfill, only if it's actually needed, here's how it could look:

const polyfillIntersectionObserver = (callback, global) => {
  if (isIntersectionObserverSupported(global)) {
    callback({ message: 'Natively supported' });
    return;
  }

  const script = global.document.createElement('script');

  script.src =
    'https://www.gstatic.com/external_hosted/intersectionobserver_polyfill/intersection-observer.min.js';

  global.document.body.appendChild(script);

  script.onload = () => callback({ message: 'Polyfill loaded' });
  script.onerror = () => callback({ message: 'Error!', error: true });
};

🔬 Breaking it down

First of all, the function receives a callback that will get executed whenever the operation is done, and a global variable that must be the window object. This function will have three different outputs:

  1. ✅ Natively supported
  2. ⚡️Polyfill executed and it's now available
  3. ❌ Error

Moving on, the first line in the function is a conditional doing a guard-clause to try to exit early if the best scenario is happening, AKA the API is natively supported:

if (isIntersectionObserverSupported(global)) {
  callback({ message: 'Natively supported' });
  return; // <- Early return 🤘
}

Then, if the conditional we just covered didn't execute, it means that the API was not supported, so a dynamic script tag is being created with the source pointing to the desired URL where the polyfill is hosted.

const script = global.document.createElement('script');

script.src =
      'https://www.gstatic.com/external_hosted/intersectionobserver_polyfill/intersection-observer.min.js';

global.document.body.appendChild(script);

Now, as the script onload method gets called, the callback will get executed with a nice message that shows that the IntersectionObserver object should exist now globally. And finally, as we need to account for errors, the callback could get called with an error property set to true.

script.onload = () => callback({ message: 'Polyfill loaded' });
script.onerror = () => callback({ message: 'Error!', error: true });

Now it's time to try the approach by calling the core function:

const myCallback = () => {};
polyfillIntersectionObserver(myCallback, window);

🎤 Demo

Putting it together, here's a CodePen showing how this could work:

Conclusions

This was a quick one, but it's interesting to see how this approach helps to have better loading times. The main takeaways:

  • If the user has a browser that supports the API, no extra file gets downloaded, parsed/compiled nor executed
  • No extra loading times, only if needed
  • The developer would have the option to decide when to dynamically execute the polyfill, meaning that the main.js file could do its own thing and then, call polyfillIntersectionObserver method on demand

I hope this was helpful and inspires you to not only do this for the Intersection Observer API, but for all the polyfills you might need.

Footnotes

  • This would've looked so much better with a Promise, but sadly it's not supported either on IE

Did you find this article valuable?

Support Wilmer Soto Capobianco by becoming a sponsor. Any amount is appreciated!