JS code-splitting with ES modules in the browser

Recently the New York Times shipped an interactive built with native JavaScript ES modules, using Rollup.js instead of Webpack to provide a 25% reduction in JS shipped. This was really interesting to me as they used code-splitting with ES module chunks, something I'd never seen before! Let's find out how they did this.

What is code-splitting?

Code-splitting is a technique where you split your code into separate bundles, this can be used to ship reduced payload sizes, control resource load priority, prevent duplication across bundles and more.

Most web-bundlers like Webpack, Rollup.js and Parcel.js use their own custom code to dynamically load split code chunks in the browser. This boilerplate code can be a size overhead on the shipped bundles - this new technique uses native browser ES modules that support the import, export statements and the dynamic import(...) function to achieve the same result but without that overhead.

How to code-split to ES Modules

Rollup.js now has experimental support for code-splitting to ES modules! Webpack however does not and has an issue open for it.

And for any browsers that do not support ES modules yet, Rich Harris (creator of Rollup.js) also created Shimport which shims import and export.

Read Jake's article to learn more about ES modules in the browser

Steps to loading (+ Shimport)

  1. Page loads and executes regular JS (no script module like <script type="module" ... />")
  2. Regular JS then dynamically loads the ES module using the import(...) function like:
    new Function('import("' + src + '")')();
  3. IF this is successful then Shimport is never even loaded - the ES module is loaded natively in the browser (70% of browsers support this)
  4. ELSE Shimport helps load the ES module as well as shimming the import and export statements to support older browsers
Shimport also supports the dynamic import(...) function which can be used to asynchronously load more chunks after the initial ES module is loaded.

Size Reduction

So where does the 25% reduction in JS come from for the NYT? Well a lot of it is probably the boilerplate code used to support code-splitting with chunks and from the transpilation of files that use import and export.

For example, if you add import('./one.js') with no other code in Webpack you get a ~2kb boilerplate in your output which is generated from this file. Example output:

!function(e,t){if("object"==typeof exports&&"object"==typeof module)module.exports=t();else if("function"==typeof define&&define.amd)define([],t);else{var n=t();for(var r in n)("object"==typeof exports?exports:e)[r]=n[r]}}(window,function(){return function(e){function t(t){for(var n,o,i=t[0],u=t[1],f=0,a=[];f<i.length;f++)o=i[f],r[o]&&a.push(r[o][0]),r[o]=0;for(n in u)Object.prototype.hasOwnProperty.call(u,n)&&(e[n]=u[n]);for(c&&c(t);a.length;)a.shift()()}var n={},r={0:0};function o(t){if(n[t])return n[t].exports;var r=n[t]={i:t,l:!1,exports:{}};return e[t].call(r.exports,r,r.exports,o),r.l=!0,r.exports}o.e=function(e){var t=[],n=r[e];if(0!==n)if(n)t.push(n[2]);else{var i=new Promise(function(t,o){n=r[e]=[t,o]});t.push(n[2]=i);var u,f=document.getElementsByTagName("head")[0],c=document.createElement("script");c.charset="utf-8",c.timeout=120,o.nc&&c.setAttribute("nonce",o.nc),c.src=function(e){return o.p+""+({}[e]||e)+".js"}(e),u=function(t){c.onerror=c.onload=null,clearTimeout(a);var n=r[e];if(0!==n){if(n){var o=t&&("load"===t.type?"missing":t.type),i=t&&t.target&&t.target.src,u=new Error("Loading chunk "+e+" failed.\n("+o+": "+i+")");u.type=o,u.request=i,n[1](u)}r[e]=void 0}};var a=setTimeout(function(){u({type:"timeout",target:c})},12e4);c.onerror=c.onload=u,f.appendChild(c)}return Promise.all(t)},o.m=e,o.c=n,o.d=function(e,t,n){o.o(e,t)||Object.defineProperty(e,t,{enumerable:!0,get:n})},o.r=function(e){"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},o.t=function(e,t){if(1&t&&(e=o(e)),8&t)return e;if(4&t&&"object"==typeof e&&e&&e.__esModule)return e;var n=Object.create(null);if(o.r(n),Object.defineProperty(n,"default",{enumerable:!0,value:e}),2&t&&"string"!=typeof e)for(var r in e)o.d(n,r,function(t){return e[t]}.bind(null,r));return n},o.n=function(e){var t=e&&e.__esModule?function(){return e.default}:function(){return e};return o.d(t,"a",t),t},o.o=function(e,t){return Object.prototype.hasOwnProperty.call(e,t)},o.p="",o.oe=function(e){throw console.error(e),e};var i=window.webpackJsonp=window.webpackJsonp||[],u=i.push.bind(i);i.push=t,i=i.slice();for(var f=0;f<i.length;f++)t(i[f]);var c=u;return o(o.s=0)}([function(e,t,n){n.e(1).then(n.t.bind(null,1,7))}])});

But using the new --experimentalCodeSplitting flag in Rollup.js it just outputs import('./one.js') here instead. Big reduction in size!

Try it!

Install Rollup.js

yarn add --dev rollup

Add two JS files.

// src/index.js
import('./one.js');

// src/one.js
console.log('hello world');

Add this to your npm scripts

{
    // ...
    "scripts": {
        "build": "rollup src/index.js -f esm --dir dist --experimentalCodeSplitting"
    }
}

Now when you run yarn build you'll output code-splitting with native ES module chunks. The output files are the same as the source files in this simple example, but it works!

I recommend trying out Shimport if you'd like to support more browsers with this approach. It's simple to setup and try out but it is still experimental, so beware!

Conclusion

I'd love to see Webpack and Parcel.js also support code-splitting to native ES modules in the future. More browsers will eventually support ES modules, so this could be the norm when it comes to code-splitting. Shimport also looks really promising with helping to bridge the browser support gap in the meantime.

Thanks for reading ❤