A colleague asked me:
These days, how do you typically serve polyfills only to browsers that need them?
I know three ready-to-use approaches for that:
polyfill.io
polyfill.io is a service that inspects the browser’s User-Agent
and serves a script with polyfills targeted specifically at that browser.
With polyfill.io, you add a single script in front of your bundle:
<script src="https://polyfill.io/v3/polyfill.min.js?features=default"></script>
<script src="/bundle.min.js"></script>
and the script serves exactly the polyfills the visitor needs.
polyfill.io also supports picking a subset of polyfills. This is useful when, out of all modern JS features, you only use a few – e.g.
Map
andPromise
– and don’t want to burden IE11 users with extra code.
Tricky parts
-
The polyfill.io script will add 50-300 ms to your Time to Interactive. The script is (obviously) hosted on a server different from yours, and loading stuff from a different server is costly. The browser will have to spend extra 50-300 ms to setup a connection to the server, and this means adding 50-300 ms to your Time to Interactive.
(Well, unless you resort to self-hosting or complex CDN hacks.)
-
The polyfill.io script might add these 50-300 ms to your First Contentful Paint as well – if you put it into
<head>
without anasync
or adefer
attribute. -
In the (unlikely) event of a polyfill.io outage, your site will either get very slow, or will break in older browsers. The outage is unlikely because polyfill.io relies on a CDN and has never gone down so far; but keep this in mind.
module/nomodule
module
/nomodule
is a pattern when you serve scripts for modern browsers with <script type="module">
, and scripts for older browsers with <script nomodule>
:
<!-- Full polyfill bundle for old browsers -->
<script nomodule src="/polyfills/full.min.js"></script>
<!-- Smaller polyfill bundle for browsers with ES2015+ support -->
<script type="module" src="/polyfills/modern.min.js"></script>
<!-- Bundle script. `defer` is required to execute
this script after the `type="module"` one -->
<script src="/bundle.min.js" defer></script>
This pattern relies on the fact that old browsers – ones that don’t support ES2015 – will not load type="module"
scripts – and will load nomodule
ones. Which means you can use nomodule
to serve ES2015 polyfills exactly to browsers that need them!
So, in the snippet above:
-
the
/polyfills/full.min.js
script will only load in browsers that don’t support ES2015 and don’t recognize thenomodule
attrubute – e.g., IE11; -
the
/polyfills/modern.min.js
script will only load in browsers that support ES2015 and recognizetype="module"
scripts – Chrome 61+, Firefox 60+, Safari 10.1+; -
the
/bundle.min.js
script will load in all browsers.
Philip Walton wrote a great detailed article about the
module
/nomodule
approach.
There’s a bunch of guides and plugins for bundles and frameworks that help to implement the
module
/nomodule
pattern, e.g.:
Tricky parts
-
Safari 10.1 has a quirk. It supports
type="module"
but doesn’t support thenomodule
attribute. If you support this Safari version, you’ll have to work around that. -
The
module
/nomodule
patters draws a split only between ES5 and ES2015+ browsers. ES2016 and newer standards added a bunch of other polyfillable features likeArray.prototype.includes()
orObject.values()
. You’ll have to serve their polyfills to all ES2015+ browsers – even though most of these browsers won’t need them. -
type="module"
scripts are always deferred. If you want to execute atype="module"
polyfill before the regular bundle script, you have to add thedefer
tag to the bundle as well.
Babel’s useBuiltIns
@babel/preset-env
has an option called useBuiltIns
. With this option, you can make Babel cherry-pick polyfills for specific browsers:
// .babelrc
{
"presets": [
["env", {
// Specify browsers you’re targeting...
"targets": "> 0.25%, not dead",
// ...and either...
"useBuiltIns": "entry",
// ...or
"useBuiltIns": "usage"
}]
]
}
With useBuiltIns: "entry"
, Babel will replace the import of core-js
– the most common polyfill library – with specific polyfills required for browsers you’re targeting. So if you’re targeting only the latest Chrome and Firefox, @babel/preset-env
will strip unnecessary polyfills for you:
// Before → 293 polifylls bundled
import 'core-js';
// After → 87 polyfills bundled
import 'core-js/modules/es.array.unscopables.flat';
import 'core-js/modules/es.array.unscopables.flat-map';
// ...
With useBuiltIns: "usage"
, Babel will go even further and only add polyfills for methods you actually use:
// Before → no polyfills bundled
console.log([5, 6, 7].includes(5));
// After → with `targets: "IE 11"` → 1 polyfill bundled
import 'core-js/modules/es.array.includes';
console.log([5, 6, 7].includes(5));
// After → with `targets: "Chrome >=70"` → 0 polyfills bundled
console.log([5, 6, 7].includes(5));
Tricky parts
-
useBuiltIns: "entry"
is not very useful if you’re targeting old browsers, like IE 11. It might remove some polyfills, likeObject.getPrototypeOf
, but most of them will stay in the bundle and would still be downloaded by everyone. -
If you use core-js 2,
useBuiltIns: "usage"
will fail to add some of the newer polyfills. For example, it won’t polyfill this code:[].flat();
because it won’t know that
.flat()
is a method that requires polyfilling.To solve this, upgrade to core-js 3 which includes the latest polyfills.
-
useBuiltIns: "usage"
will not add polyfills for your dependencies – unless you pipenode_modules
through Babel. So if you aren’t cautious enough, you might get runtime error in older browsers. -
In some cases,
useBuiltIns: "usage"
will add excessive polyfills. For example, with this code:import { myVar } from './myModule'; myVar.includes();
@babel/preset-env
has no way of knowing whethermyVar
is an array or a string – so it’d bundle polyfills both forArray.prototype.includes
andString.prototype.includes
.
Summing up
All three widely supported solutions have their benefits and drawbacks:
- polyfill.io → very easy to setup and doesn’t ship anything to modern browsers – but costly in terms of TTI (and, sometimes, FCP)
- module/nomodule → has wide tooling support but only strips ES2015− polyfills
- Babel’s
useBuiltIns
: easy to setup for everyone who’s already using@babel/preset-env
; but either not very useful if you’re targeting older browsers, or requires you to complilenode_modules
as well
The best solution would be a custom one: something that combines benefits of polyfill.io and Babel’s useBuiltIns
but doesn’t incur their costs. To do this, you may:
- build multiple bundles using Babel’s
useBuiltIns
and different target browsers – and serve the right bundle based on the user agent; - or follow a Philip Walton’s approach with client-side conditional loading.
Thanks to Nicoló Ribaudo for helping with the Babel section.