Smart Bundling: How To Serve Legacy Code Only To Legacy Browsers
Smart Bundling: How To Serve Legacy Code Only To Legacy Browsers
Smart Bundling: How To Serve Legacy Code Only To Legacy Browsers
Shubham Kanodia
2018-10-15T14:30:13+02:00
2018-10-16T10:09:16+00:00
A website today receives a large chunk of its traffic from evergreen browsers — most of which have good support for ES6+, new JavaScript standards, new web platform APIs and CSS attributes. However, legacy browsers still need to be supported for the near future — their usage share is large enough not to be ignored, depending on your user base.
A quick look at caniuse.com’s usage table reveals that evergreen browsers occupy a lion’s share of the browser market — more than 75%. In spite of this, the norm is to prefix CSS, transpile all of our JavaScript to ES5, and include polyfills to support every user we care about.
While this is understandable from a historical context — the web has always been about progressive enhancement — the question remains: Are we slowing down the web for the majority of our users in order to support a diminishing set of legacy browsers?
The different compatibility layers of a web app. (View large version)
The Cost Of Supporting Legacy Browsers
Let’s try to understand how different steps in a typical build pipeline can add weight to our front-end resources:
Transpiling To ES5
To estimate how much weight transpiling can add to a JavaScript bundle, I took a few popular JavaScript libraries originally written in ES6+ and compared their bundle sizes before and after transpilation:
Library
Size
(minified ES6)
Size
(minified ES5)
Difference
TodoMVC
8.4 KB
11 KB
24.5%
Draggable
77.9 KB
53.5 KB
31.3%
Luxon
75.4 KB
100.3 KB
24.8%
Video.js
237.2 KB
335.8 KB
29.4%
PixiJS
370.8 KB
452 KB
18%
On average, untranspiled bundles are about 25% smaller than those that have been transpiled down to ES5. This isn’t surprising given that ES6+ provides a more compact and expressive way to represent the equivalent logic and that transpilation of some of these features to ES5 can require a lot of code.
ES6+ Polyfills
While Babel does a good job of applying syntactical transforms to our ES6+ code, built-in features introduced in ES6+ — such as Promise
, Map
and Set
, and new array and string methods — still need to be polyfilled. Dropping in babel-polyfill
as is can add close to 90 KB to your minified bundle.
Front-end is messy and complicated these days. That’s why we publish articles, printed books and webinars with useful techniques to improve your work. Even better: Smashing Membership with a growing selection of front-end & UX goodies. So you get your work done, better and faster.
Explore Smashing Membership ↬
Web Platform Polyfills
Modern web application development has been simplified due to the availability of a plethora of new browser APIs. Commonly used ones are fetch
, for requesting for resources, IntersectionObserver
, for efficiently observing the visibility of elements, and the URL
specification, which makes reading and manipulation of URLs on the web easier.
Adding a spec-compliant polyfill for each of these features can have a noticeable impact on bundle size.
CSS Prefixing
Lastly, let’s look at the impact of CSS prefixing. While prefixes aren’t going to add as much dead weight to bundles as other build transforms do — especially because they compress well when Gzip’d — there are still some savings to be achieved here.
Library
Size
(minified, prefixed for last 5 browser versions)
Size
(minified, prefixed for last browser version)
Difference
Bootstrap
159 KB
132 KB
17%
Bulma
184 KB
164 KB
10.9%
Foundation
139 KB
118 KB
15.1%
Semantic UI
622 KB
569 KB
8.5%
A Practical Guide To Shipping Efficient Code
It’s probably evident where I’m going with this. If we leverage existing build pipelines to ship these compatibility layers only to browsers that require it, we can deliver a lighter experience to the rest of our users — those who form a rising majority — while maintaining compatibility for older browsers.
Forking our bundles. (View large version)
This idea isn’t entirely new. Services such as Polyfill.io are attempts to dynamically polyfill browser environments at runtime. But approaches such as this suffer from a few shortcomings:
- The selection of polyfills is limited to those listed by the service — unless you host and maintain the service yourself.
- Because the polyfilling happens at runtime and is a blocking operation, page-loading time can be significantly higher for users on old browsers.
- Serving a custom-made polyfill file to every user introduces entropy to the system, which makes troubleshooting harder when things go wrong.
Also, this doesn’t solve the problem of weight added by transpilation of the application code, which at times can be larger than the polyfills themselves.
Let see how we can solve for all of the sources of bloat we’ve identified till now.
Tools We’ll Need
- Webpack
This will be our build tool, although the process will remain similar to that of other build tools, like Parcel and Rollup. - Browserslist
With this, we’ll manage and define the browsers we’d like to support. - And we’ll use some Browserslist support plugins.
1. Defining Modern And Legacy Browsers
First, we’ll want to make clear what we mean by “modern” and “legacy” browsers. For ease of maintenance and testing, it helps to divide browsers into two discrete groups: adding browsers that require little to no polyfilling or transpilation to our modern list, and putting the rest on our legacy list.
Browsers that support ES6+, new CSS attributes, and browser APIs like Promises and Fetch. (View large version)
A Browserslist configuration at the root of your project can store this information. “Environment” subsections can be used to document the two browser groups, like so:
[modern]
Firefox >= 53
Edge >= 15
Chrome >= 58
iOS >= 10.1
[legacy]
> 1%
The list given here is only an example and can be customized and updated based on your website’s requirements and the time available. This configuration will act as the source of truth for the two sets of front-end bundles that we will create next: one for the modern browsers and one for all other users.
2. ES6+ Transpiling And Polyfilling
To transpile our JavaScript in an environment-aware manner, we’re going to use babel-preset-env
.
Let’s initialize a .babelrc
file at our project’s root with this:
{
"presets": [
["env", { "useBuiltIns": "entry"}]
]
}
Enabling the useBuiltIns
flag allows Babel to selectively polyfill built-in features that were introduced as part of ES6+. Because it filters polyfills to include only the ones required by the environment, we mitigate the cost of shipping with babel-polyfill
in its entirety.
For this flag to work, we will also need to import babel-polyfill
in our entry point.
// In
import "babel-polyfill";
Doing so will replace the large babel-polyfill
import with granular imports, filtered by the browser environment that we’re targeting.
// Transformed output
import "core-js/modules/es7.string.pad-start";
import "core-js/modules/es7.string.pad-end";
import "core-js/modules/web.timers";
…
3. Polyfilling Web Platform Features
To ship polyfills for web platform features to our users, we will need to create two entry points for both environments:
require('whatwg-fetch');
require('es6-promise').polyfill();
// … other polyfills
And this:
// polyfills for modern browsers (if any)
require('intersection-observer');
This is the only step in our flow that requires some degree of manual maintenance. We can make this process less error-prone by adding eslint-plugin-compat to the project. This plugin warns us when we use a browser feature that hasn’t been polyfilled yet.
4. CSS Prefixing
Finally, let’s see how we can cut down on CSS prefixes for browsers that don’t require it. Because autoprefixer
was one of the first tools in the ecosystem to support reading from a browserslist
configuration file, we don’t have much to do here.
Creating a simple PostCSS configuration file at the project’s root should suffice:
module.exports = {
plugins: [ require('autoprefixer') ],
}
Putting It All Together
Now that we’ve defined all of the required plugin configurations, we can put together a webpack configuration that reads these and outputs two separate builds in dist/modern
and dist/legacy
folders.
const MiniCssExtractPlugin = require('mini-css-extract-plugin')
const isModern = process.env.BROWSERSLIST_ENV === 'modern'
const buildRoot = path.resolve(__dirname, "dist")
module.exports = {
entry: [
isModern ? './polyfills.modern.js' : './polyfills.legacy.js',
"./main.js"
],
output: {
path: path.join(buildRoot, isModern ? 'modern' : 'legacy'),
filename: 'bundle.[hash].js',
},
module: {
rules: [
{ test: /.jsx?$/, use: "babel-loader" },
{
test: /.css$/,
use: [MiniCssExtractPlugin.loader, 'css-loader', 'postcss-loader']
}
]},
plugins: {
new MiniCssExtractPlugin(),
new HtmlWebpackPlugin({
template: 'index.hbs',
filename: 'index.html',
}),
},
};
To finish up, we’ll create a few build commands in our package.json
file:
"scripts": {
"build": "yarn build:legacy && yarn build:modern",
"build:legacy": "BROWSERSLIST_ENV=legacy webpack -p --config webpack.config.js",
"build:modern": "BROWSERSLIST_ENV=modern webpack -p --config webpack.config.js"
}
That’s it. Running yarn build
should now give us two builds, which are equivalent in functionality.
Serving The Right Bundle To Users
Creating separate builds helps us achieve only the first half of our goal. We still need to identify and serve the right bundle to users.
Remember the Browserslist configuration we defined earlier? Wouldn’t it be nice if we could use the same configuration to determine which category the user falls into?
Enter browserslist-useragent. As the name suggests, browserslist-useragent
can read our browserslist
configuration and then match a user agent to the relevant environment. The following example demonstrates this with a Koa server:
const Koa = require('koa')
const app = new Koa()
const send = require('koa-send')
const { matchesUA } = require('browserslist-useragent')
var router = new Router()
app.use(router.routes())
router.get('/', async (ctx, next) => {
const useragent = ctx.get('User-Agent')
const isModernUser = matchesUA(useragent, {
env: 'modern',
allowHigherVersions: true,
})
const index = isModernUser ? 'dist/modern/index.html', 'dist/legacy/index.html'
await send(ctx, index);
});
Here, setting the allowHigherVersions
flag ensures that if newer versions of a browser are released — ones that are not yet a part of Can I Use’s database — they will still report as truthy for modern browsers.
One of browserslist-useragent
‘s functions is to ensure that platform quirks are taken into account while matching user agents. For example, all browsers on iOS (including Chrome) use WebKit as the underlying engine and will be matched to the respective Safari-specific Browserslist query.
It might not be prudent to rely solely on the correctness of user-agent parsing in production. By falling back to the legacy bundle for browsers that aren’t defined in the modern list or that have unknown or unparseable user-agent strings, we ensure that our website still works.
Conclusion: Is It Worth It?
We have managed to cover an end-to-end flow for shipping bloat-free bundles to our clients. But it’s only reasonable to wonder whether the maintenance overhead this adds to a project is worth its benefits. Let’s evaluate the pros and cons of this approach:
1. Maintenance And Testing
One is required to maintain only a single Browserslist configuration that powers all of the tools in this pipeline. Updating the definitions of modern and legacy browsers can be done anytime in the future without having to refactor supporting configurations or code. I’d argue that this makes the maintenance overhead almost negligible.
There is, however, a small theoretical risk associated with relying on Babel to produce two different code bundles, each of which needs to work fine in its respective environment.
While errors due to differences in bundles might be rare, monitoring these variants for errors should help to identify and effectively mitigate any issues.
2. Build Time vs. Runtime
Unlike other techniques prevalent today, all of these optimizations occur at build time and are invisible to the client.
3. Progressively Enhanced Speed
The experience of users on modern browsers becomes significantly faster, while users on legacy browsers continue to get served the same bundle as before, without any negative consequences.
4. Using Modern Browser Features With Ease
We often avoid using new browser features due to the size of polyfills required to use them. At times, we even choose smaller non-spec-compliant polyfills to save on size. This new approach allows us to use spec-compliant polyfills without worrying much about affecting all users.
Differential Bundle Serving In Production
Given the significant advantages, we adopted this build pipeline when creating a new mobile checkout experience for customers of Urban Ladder, one of India’s largest furniture and decor retailers.
In our already optimized bundle, we were able to squeeze savings of approximately 20% on the Gzip’d CSS and JavaScript resources sent down the wire to modern mobile users. Because more than 80% of our daily visitors were on these evergreen browsers, the effort put in was well worth the impact.
Further Resources
- “Loading Polyfills Only When Needed”, Philip Walton
@babel/preset-env
A smart Babel preset- Browserslist “Tools”
Ecosystem of plugins built for Browserslist - Can I Use
Current browser marketshare table
(dm, ra, yk, il, al)
From our sponsors: Smart Bundling: How To Serve Legacy Code Only To Legacy Browsers