Introduction
The size and complexity of web applications are constantly growing each year. We are constructing sophisticated tools, which require more JavaScript libraries to be shipped to clients' browsers. In 2020 the median amount of JavaScript included on a page topped 440KBs. This trend continuously adds on to the total weight of the page and is affecting its performance, both load time and runtime.
Furthermore, the execution environment of JavaScript is constantly changing. Browsers are shipping new APIs, adding support for new JavaScript syntax, and new standards are emerging. As a developer, you’d like to leverage those to improve user experience, but how to do that if you need the website to work well on legacy browsers as well?
The answer has traditionally been polyfilling and transpilation. We shall touch briefly on these approaches later in this article. The issue, however, is that these approaches add even more JavaScript into the application.
In this article, we explain why and how differential loading is used to support legacy browsers without bloating the JavaScript shipped to modern browsers.
What is Differential Loading Used For?
Differential loading ensures that the most modern browsers are not bloated with unnecessary polyfills and transpiled code targeting legacy browsers, while legacy browsers receive the full package to ensure compatibility.
Differential loading also resolves the need for unnecessary transpilation down to ECMAScript 5 and redundant polyfills inclusion for modern browsers that do not require it.
JavaScript Language and Browser APIs Evolution
JavaScript and browsers have come a long way, and the ecosystem is constantly changing. Let us take a look at the main dimensions of this change, which are the main drivers for the technique in subject.
JavaScript Syntax Evolution and Transpilation
JavaScript is an implementation of ECMAScript specification as well as Node.js. In the past, ECMAScript version releases were infrequent, to the extent that we needed to wait years for the release of ES6. The body governing this process is TC39, and, thankfully, they adopted a more incremental approach, which results in much more frequent version releases.
Some of the most notable features introduced by ES6 were classes, template literals, arrow functions, and many more. You can refer to this compatibility table for reference and more details on the ES6 scope. All of these features are widely available nowadays in modern browsers. Since then, we have had multiple yearly releases, including more improvements to the language like async/await or recently the optional chaining operator.
The new language features go through multiple approval stages, which can be monitored on the TC39 official GitHub profile.
Imagine you wanted to leverage modern language constructs. If it weren't for transpilation, you would need to wait years for browsers to support it fully. Instead, we can transpile modern language syntax into constructions available to legacy browsers and thus instantly start leveraging new capabilities in our projects.
Note: One has to bear in mind that not all language constructs can be transpiled.
Browser APIs Evolution and Polyfills
The body governing the evolution of browser APIs is W3C. One can refer to the same matrix as for ECMAScript to see what new methods or properties are added to the classes provided globally in the browsers. On top of that there is a whole new range of features, like the service workers, WebSocket API, or MutationObserver.
Some of those can be polyfilled. A polyfill is a JavaScript library that adds or patches a functionality in legacy browsers. For instance, MutationObserver can be polyfilled, while WebSocket connection or Service Worker cannot.
JavaScript Dependency Management
For a very long time, JavaScript had no mechanism of dependency handling. One would need to manually ensure that all required dependencies for their website or web app were correctly included and ordered.
This was turning quickly into a maintenance nightmare as the complexity and amount of dependencies grew. That approach was clearly not scalable and several approaches were proposed to fill in this gap before the official standard emerged.
RequireJS
One module loading approach is the Asynchronous Module Definition (AMD). RequireJS is an example implementation of this specification. It is a first attempt to solve the problem of missing dependency-handling functionality in browsers.
Its asynchronous nature means that code requiring dependencies is executed as part of a callback function which is invoked only after said dependencies are made available.
CommonJS
CommonJS was developed primarily for Node.js and, as opposed to AMD, it is a synchronous module loading mechanism. It is possible to leverage it on the browser side using build tools like browserify or webpack.
Universal Module Definition
There was a mixture in the ecosystem, with some packages leveraging the Asynchronous Module Definition and some the Synchronous Module loading mechanism. Hence, the Universal Module Definition (UMD) was created.
As per its name, Universal Module Definition is meant to be a universal syntax, working on both the server and client side. It detects what setup it is run on (AMD or CommonJS) and executes module handling accordingly.
ECMAScript Modules
All this brought us to the ECMAScript Modules (ESM), a language-level standardized way to handle modules, available in both Node.js and client side in modern browsers, and leveraged in differential loading.
Transpilation
Transpilation is the process of compilation of JavaScript source code based on older ECMAScript specification.
In the example above, you can see ES6 class definition using modern syntax, and, on the right, the transpiled version leveraging a closure and a function constructor.
There are a number of tools to handle this process, the two most popular ones being TypeScript and babel. Transpilation is usually a build step in your application. In case of differential loading, the result would be two versions of the JavaScript codebase – one transpiled for the use of legacy browsers and one original meant for modern browsers.
Polyfills
On the other hand, polyfills are meant to provide an implementation or a patch for a standard browser API that is not available yet, for example Object.keys or String.prototype.padStart.
Core-js is an example of a popular set of polyfills, being almost a one-stop shop for supporting recent APIs in legacy browsers.
The common denominator for functionalities that can be polyfilled is the possibility of imitating said functionality with existing APIs and JavaScript language features.
How Does Differential Loading Work?
The base for differential loading is a newly introduced module type for HTML script tags, as well as the new nomodule attribute available for script tags. The concept is pretty easy and goes as follows:
Modern browsers recognize both module typescript and nomodule attribute, thus are loading and processing the module only. | Legacy browsers do not know how to handle module type script tags and thus ignoring them completely, while using the one marked with the attribute nomodule as it is the standard text/javascript type. |
In the snippet above, you can see that Chrome downloads and processes esm.bundle.mjs file since it is included using the script tag of type module. Chrome ignores the es5.bundle.js since the script tag is marked as nomodule. IE9, on the other hand, does the opposite since it is not able to process script tags of type module, and the other is perceived as a regular script tag.
Should you wish to read more about the nature of ECMAScriptmodules you can refer to this succinct write-up.
Differential Loading in Angular
Differential loading is handled by the Angular Client automatically, based on the browserlist target for your application. As of Angular 10, it assumes one is targeting only the modern subset of browsers, thus disabling ES5 compilation and differential loading altogether. However, upon the update of the supported browsers list, this behavior is amended based on the list provided.
As seen in the snippet above, Angular Client generated two sets of bundles for our application as required for differential loading – a set of ES2015+ and ES5 resources. It also amended the index.html file in the background with the script tags required to load up and bootstrap the application.
Looking at the generated files, the saving in terms of payload size is clearly visible. For instance, common-es5 bundle (legacy browsers) weighs 15KB, while common-es2015 bundle (modern browsers) weighs 13.1KB. The difference is even more significant when we look at polyfills – ES5 (legacy browsers) is almost 70KB, and ES2015 (modern browsers) is only 130 bytes!
How to Manually Setup of Differential Loading
To have differential loading set up, one has to have a build outputting two distinct bundle types. One build generating an ES5 bundle and another generating the modern one, ES2015+.
It is really easy to switch between compilation targets in both Babel and Typescript. It is enough to run your transpiler via a bundler of your choice (i.e., webpack, rollup, or parcel) twice, providing a different transpiler configuration file for it to digest. For further details, see example .babelrc configuration file below:
{
"presets": [
["env", {
"targets": {
"browsers": ["last 2 versions"] // target determined from browsers list
}
}]
],
"plugins": ["external-helpers"]
}
And this example TypeScript configuration file:
{
"compileOnSave": false,
"compilerOptions": {
"baseUrl": "./",
"outDir": "./dist/out-tsc",
"sourceMap": true,
"module": "esnext",
"moduleResolution": "node",
"importHelpers": true,
"target": "es2015", // target ES version
"typeRoots": [
"node_modules/@types"
],
"lib": [
"es2018",
"dom"
]
}
}
All those steps can be composed into a single command using npm or yarn scripts.
{
"name": "the-portal",
"version": "0.0.1",
"scripts": {
"build:diff": "yarn webpack:es5 && yarn webpack:es2015",
"webpack:es5": "webpack --config config/webpack.es5.js --mode production",
"webpack:es2015": "webpack --config config/webpack.es2015.js --mode production"
},
…
}
The example above invokes the build:diff script, which in turn would call webpack twice to produce two versions of the application, according to the configuration files it was fed. Once this is done, all you have to do is include the produced bundles in your index.html file using the technique described above.
Conclusion
Differential loading is an interesting performance optimization one can apply in every web application. It not only yields a reduction in the weight of the application and thus reduces the initial load time but also limits the amount of processing the browser has to do on loaded JavaScript.
Differential loading enables potential runtime performance improvements in modern browsers thanks to the fact they leverage native implementations of APIs that would have been otherwise polyfilled with custom, slower JavaScript. The only price to be paid is a slightly more complex build setup with application builds taking longer.
Differential loading is definitely worth looking into as the setup is not complicated and, in some cases, like for Angular, available out of the box. The concept is easy to grasp and thus does not add much to the overall setup complexity, giving measurable returns in the form of performance and UX improvements.
Setting it up for your application will not do any damage, but the gains from it highly depend on the browsers list that your application has to support as well as APIs and JavaScript syntax used in your codebase. Differential loading is always beneficial but yields greatest savings when the app leverages the latest JavaScript language features and has a wide spectrum of browsers to support.