JavaScript Bundle Optimization – Polyfills

If you are lucky enough to only support a small subset of browsers (for example, you are targeting a controlled set of users), feel free to move along. However, if your website is open to the broader internet, you are likely dealing with polyfills in an attempt to support as many different browsers and devices as you can afford. While polyfills can help in making our websites more accessible, the polyfills included and, more importantly, how you include them can have a measurable effect on first contentful paint and user experience.

What is a Polyfill?

A polyfill is a piece of code (usually JavaScript on the Web) used to provide modern functionality on older browsers that do not natively support it.

via Mozilla Developer Network Web Docs

In a perfect world, every one of your users would be using a browser that supports every one of your favorite JavaScript, CSS, and browser features. Whether it be CSS Grid or JavaScript promises, everything would work as expected. But, as this isn’t the case, polyfills allow for us to use the latest browser-features in our code by including the code to fill in missing features.

Unfortunately, including polyfills with your deployed code brings with it two problems:

  1. You need to know and keep track of what polyfills to include within your code bundles.
  2. If you bundle your polyfills with your core code, they are downloaded by all users – not just those that need them.

While polyfill-heavy browsers like Internet Explorer 11 are gradually becoming more officially obsolete (So You’re Looking to Drop IE11 Support), it is still worth spending some time thinking through what browsers you support and the appropriate balance between performance and developer experience for your project.

So, how do we support older browsers without bogging developers down with more maintenance and without penalizing the bulk of our users? The following is a collection of three* approaches that might be useful (individually or in combination) depending on the requirements of your specific application.

1. Babel preset-env (and browserlist)

the Babel tip

Chances are if you are building a “modern” web application you are likely already using Babel in some capacity. To improve developer experience and ensure only necessary polyfills are shipped to the end-user, Babel’s @bable/preset-env is a great place to start.

At a high level, @babel/preset-env dynamically transforms JavaScript syntax and injects needed core-js polyfills as needed by the application to support a specific set of browsers (you can read more about browserlist configs here). Specifically relating to polyfills, we are interested in the useBuiltIns config option and the following two settings:

  1. entry – all polyfills are injected at a single location. Useful in low-polyfill-count applications where all polyfills upfront does not negatively impact first contentful paint.
  2. usage – polyfills are injected in the file in which they are used, leaving it up to the bundler to only load them once. Useful for applications that see performance benefits of spreading polyfill cost across various bundles.

Which setting you use is entirely dependent on the unique requirements and performance needs of your application. Do keep in mind that only core-js polyfills are automatically injected and that you will still need to be aware of any other feature gaps you may have.

2. Lazy-load

the Webpack tip

Whether you use @babel/preset-env or not, you will likely have some polyfills that need to be manually maintained. Although this means we still have a developer burden, it does provide an opportunity to be selective about when we load application’s polyfills.

By using a combination of feature detection and lazy loading, we can reduce the polyfills that modern browsers need to download, while still fully supporting old browsers. At its simplest, it may look like the following:

// Check if the browser has a fetch implementation - if not, download & polyfill
if (!window.fetch) {
  await import('isomorphic-fetch');
}

This approach works fairly well for JavaScript features, but you might have to get more creative when it comes to deciding whether CSS or browser features need polyfilling.

if (isSomehowLegacy) {
  await import('intersection-observer');
}

One last thing to note on this approach—the examples above may result in individual file downloads for each polyfill. Having a bunch of individual polyfill file downloads on your initial page load may be a performance hit, so some tweaking of your webpack bundle chunking may be necessary.

3. Multiple Bundles

time to over-engineer

Another approach to reducing the amount of code served to users is to provide different code bundles to different browsers. This allows you to reduce polyfill bloat when serving users using newer browsers, leading to faster download times.

I know I said I would be talking through three approaches, but this is where I cheat and list three more similar, but different approaches.

3.1 Dual Builds – ES6 Modules

If you are working with a serverless client-rendered application, one multi-build option to consider is to generate two sets of JavaScript bundles – one set of bundles for browsers that support ES6 Modules and one for those that do not.

<!-- Browsers supporting modules download "main.mjs" -->
<script type="module" src="main.mjs"></script>

<!-- All other browser fall back to the more polyfill heavy "main.bundle.js" -->
<script nomodule src="main.bundle.js"></script>

I won’t go into much detail on the specifics of the approach here, as these two articles already outline the approach in detail quite well:

I will, however, call out that your browser support requirements may make this technique unnecessary. Can I Use estimates that 92%+ of global traffic already supports ES Modules, with most desktop browsers having supported ES6 modules since 2018. Unless you need to support Internet Explorer or certain mobile browsers, the differences between your bundles will likely be minimal. It is worth taking a look at Can I Use before implementing to see if this approach will have a worthwhile impact with your specific needs.

Vue

Are you using Vue with the Vue CLI? You’re in luck! The CLI has support built-in for generating “modern” module bundles by using the —modern flag. See the CLI documentation for more info.

3.2 Custom Polyfill Bundles – Polyfill.io

If you are willing to use a third-party service for polyfills or can run your own standalone polyfill sever, Polyfill.io is another option for providing different polyfill bundles to different users.

a service which accepts a request for a set of browser features and returns only the polyfills that are needed by the requesting browser.

Polyfill.io uses server-side User-Agent sniffing to generate a polyfill bundle per user. This means that as a developer, you no longer need to bundle in polyfills supported by the service and can instead leave it up to the service to provide each user with exactly what they need. The service can be used directly or you can manually host your own server using their open source node service polyfill-library.

In use, your application can get any supported polyfill that the user might need:

<script
  src="https://polyfill.io/v3/polyfill.min.js"
  type="text/javascript"
></script>

Or, only request specific polyfill your application might need:

<!-- Only retrieve es5, es2015, Array.isArray -->
<script
  src="https://polyfill.io/v3/polyfill.min.js?features=es5%2Ces2015%2CArray.isArray"
  type="text/javascript"
></script>

There are a few things to consider when looking at this approach. First and foremost, as this is an all-or-nothing approach to polyfills, your application’s initial paint may be delayed until the bundle has been downloaded. Deferring polyfill downloads that are only needed by the application later is a manual process. Also worth keeping in mind, if you want to minimize the number of polyfills that the user has to download, the developer will once again need to manually define the needed polyfills.

3.3 n Bundles + User-Agent Sniffing

This poorly named section combines most of the approaches that have been talked about so far into a hybrid that provides a balance between the developer experience and user experience.

At a high level:

  • Setup your application to take advantage of the automatic polyfilling support of @babel/preset-env
  • Lazy load any additional polyfills as needed.
  • Determine how many different builds you might need (the sky is the limit, but consider limiting to a two-build modern & legacy setup) and configure browserlist to reflect the different support browsers.
  • Finally, use User-Agent sniffing server-side to appropriately server up code to users (based on your previously defined browserlist config).
    • As a bonus, in addition to “modern” bundles having fewer polyfills, the need to transpile JavaScript code down to ES5 is also removed, potentially leading to faster parsing times by newer browsers.

For a fully detailed example, see Smashing Magazine’s write-up on how to tie your browserlist config file into a modern/legacy dual-build setup.

With a solid combination of simplified developer experience and improved user experience – what’s not to like? While day-to-day developer experience may improve, by serving multiple potentially unique code sets to different users, testing and debugging of issues may become more complex. Similarly, this approach also clearly increases the complexity of an application’s build configuration.

Wrapping Up

So, what’s the best approach? In my personal opinion, stating with @babel/preset-env for core-js polyfills while lazy loading anything else tends to gets things moving in a good direction. But as always, it depends on your specific requirements and application.

About the Author

Daniel Testa profile.

Daniel Testa

Principal Consultant

A frontend developer with a focus on Javascript technologies.

Leave a Reply

Your email address will not be published. Required fields are marked *

Related Blog Posts
An Exploration in Rust: Musings From a Java/C++ Developer
Why Rust? It’s fast (runtime performance) It’s small (binary size) It’s safe (no memory leaks) It’s modern (build system, language features, etc) When Is It Worth It? Embedded systems (where it is implied that interpreted […]
Getting Started with CSS Container Queries
For as long as I’ve been working full-time on the front-end, I’ve heard about the promise of container queries and their potential to solve the majority of our responsive web design needs. And, for as […]
Simple improvements to making decisions in teams
Software development teams need to make a lot of decisions. Functional requirements, non-functional requirements, user experience, API contracts, tech stack, architecture, database schemas, cloud providers, deployment strategy, test strategy, security, and the list goes on. […]
Creating Mocks For Unit Testing in Go
Unit testing is an important part of any project, and Go built its framework with a testing package; making unit testing part of the language. This testing framework is good for most scenarios, but you […]