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:
- You need to know and keep track of what polyfills to include within your code bundles.
- 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:
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.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:
- “Deploying ES2015+ Code in Production Today” by Philip Walton
- “Serve modern code to modern browsers for faster page loads” by Houssein Djirdeh
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.