November 21, 2016

We don't need your polyfills!

In the fast changing world of HTML5, we've grown accustomed to using polyfills to bring future and current features to all browsers. Browsers are starting to catch up and support these features natively, yet we continue to bundle code that may not be used. Find out how Webpack's chunking and code splitting can be used to only download polyfills when necessary.

Update Jan 30th 2017: Webpack 2 final has deprecated System.import in favor of import() which complies with the spec. This article has been updated to the latest syntax.

Edit: Promises are used internally in Webpack's import, so the Promise API polyfill is required. Other polyfills will work using this technique. Article has been updated to reflect that.

After optimizing my last project to use code splitting and chunks, I was left with the following bundle structure:

  • room-widget.js - Wrapper code
  • room-widget.1.js - MobileDetect library (third-party)
  • room-widget.0.js - 3D assets
  • room-widget.2.js - 3D renderer code

The wrapper is only responsible for conditionally loading the rest of the bundle, yet it is about 10KB, which made me curious. After looking at the source, I realized both Promise and Fetch API polyfills were part of the wrapper chunk due to them being defined in Webpack's entry point as recommended by most tutorials online:

 entry: {
        'floor-simulator': [
           'es6-promise/auto', 
           'whatwg-fetch', 
           config.entryPoint
        ]
    },

I was immediately reminded of something that's bugged me about polyfills for the a while. When distributing code, we bundle polyfills regardless of whether the target browser has native support for them or not. This results in dead code being shipped for no reason, negatively impacting download size and load times.

This gave me an idea...

we bundle polyfills regardless of whether the target browser has native support for them or not

First, I moved the polyfills into their own file polyfill.js file that exports a function which returns a promise. I copied the code the polyfill uses to check for native availability and conditionally loaded the modules using Webpack's import calls:

// file: polyfill.js
export default function polyfill() {
    const promises = [];
    if (!window.fetch) {
        promises.push(import('whatwg-fetch'));
    }
    ... other polyfills
    return Promise.all(promises);
}

The polyfill Promise needs to resolve before any code that uses it is initialized, so the best place is in the constructor.

// file: main.jsx
import polyfill from './polyfill';
...

constructor(props) {
    super(props);
    polyfill()
         .then(() => // polyfills applied, initialize all the things!
...

During build time, Webpack will create a separate chunk for each polyfill, and at run-time, these will be conditionally downloaded based on whether they are natively supported in the browser:

  • room-widget.js - Core widget wrapper code
  • room-widget.3.js - window.fetch polyfill
  • room-widget.1.js - MobileDetect library
  • room-widget.2.js - 3D assets
  • room-widget.4.js - 3D renderer code

Note: Webpack's import uses Promises so the Promise polyfill will have to be bundled

Results

Latest Chrome has Fetch APIs natively, so the chunk is not downloaded:

Safari doesn't support fetch, so it fetches room-widget.2.js:

While working on large projects it is not uncommon to have multiple packages that all supply the same polyfill. This method ensures that the user doesn't suffer from downloading redundant code, and we rely on native browser features as much as possible. If one package already polyfilled a feature, another packages would simply ignore it and not download it.