Recently I was listening to the Shop Talk Show and heard about differential serving. The idea behind differential serving is that you serve different bundles to different browsers.
The benefit here is instead of compiling down to the lowest supported set of features which is probably IE11 for most of us, we can serve an IE11 bundle and a modern bundle for the evergreen browsers.
That seems like a pretty good win. But how well does this work?
Example
To do differential serving we actually don't need much code. Here is small example:
<div id="root-es5"></div>
<div id="root-esm"></div>
<!-- ES5 and below JS -->
<script nomodule src="/es5.js"></script>
<!-- ES6 and above JS -->
<script type="module" src="/esm.js"></script>
The key parts are the the two script tags. <script nomodule>
is used by older browsers to parse and execute that specific bundle and <script type="module">
is used by newer browsers. The idea is that we leave the work up to the browsers to determine which bundle to serve.
Generate Two Bundles
To generate two bundles you will need to update your build config to output two bundles, one targeting older browsers such as IE11 and one targeting newer browsers that support the <script type="module">
. To do this you can use @babel/preset-env
and specify the browser targets.
Example webpack config:
module.exports = [
{
...
output: {
path: path.resolve(__dirname, 'dist'),
filename: '[name].legacy.js'
},
module: {
rules: [
{
test: /\.js$/,
exclude: /node_modules/,
use: {
loader: 'babel-loader',
options: {
presets: [
['@babel/preset-env',
{
targets: {
browsers: [
/**
* Browser List: https://bit.ly/2FvLWtW
* `defaults` setting gives us IE11 and others at ~86% coverage
*/
'defaults'
]
},
useBuiltIns: 'usage',
modules: false,
corejs: 2
}]
]
}
}
}
]
}
...
},
{
...
output: {
path: path.resolve(__dirname, 'dist'),
filename: '[name].esm.js'
},
module: {
rules: [
{
test: /\.js$/,
exclude: /node_modules/,
use: {
loader: 'babel-loader',
options: {
presets: [
['@babel/preset-env',
{
targets: {
browsers: [
/**
* Browser List: https://bit.ly/2Yjs58M
*/
'Edge >= 16',
'Firefox >= 60',
'Chrome >= 61',
'Safari >= 11',
'Opera >= 48'
]
},
useBuiltIns: 'usage',
modules: false,
corejs: 2
}]
]
}
}
}
]
},
...
}
];
One thing worth noting is that HTML Webpack Plugin currently doesn't have built in support for generating the different script tag outputs. Luckily there are some plugins that help solve this:
Does it work?
One question you have may have been wondering is, does this really work? The answer is yes but there are a few issues. On Safari 10.1, the browser downloads both bundles and executes both bundles.
Edge and IE seem to have issues with downloading both bundles as well as Firefox 59.
That said, I still think this is a good idea to implement if you can.
Alternative Approach
Another more controlled approach is using user agent detection to find out what browser is making the request and serving the correct bundle based off of that. There is a package called browserslist-useragent that will parse the user agent string and match it against a browser list that you might already have in your .browserslistrc
file.
const express = require('express');
const { matchesUA } = require('browserslist-useragent');
const exphbs = require('express-handlebars');
...
app.use((req, res, next) => {
try {
const ESM_BROWSERS = [
'Edge >= 16',
'Firefox >= 60',
'Chrome >= 61',
'Safari >= 11',
'Opera >= 48'
];
const isModuleCompatible = matchesUA(req.headers['user-agent'], { browsers: ESM_BROWSERS, allowHigherVersions: true });
res.locals.isModuleCompatible = isModuleCompatible;
} catch (error) {
console.error(error);
res.locals.isModuleCompatible = false;
}
next();
});
app.get('/', (req, res) => {
res.render('home', { isModuleCompatible: res.locals.isModuleCompatible });
});
This approach gives you a little more control over what is getting sent to the user.
Summary
In the end, differential serving is a great performance win without having to rewrite any of your existing client code. There are some tradeoffs as to which approach is best but that's for you to decide.
Here is a list of some helpful links if you are interested in learning more:
- https://github.com/johnstew/differential-serving
- https://philipwalton.com/articles/deploying-es2015-code-in-production-today/
- https://calendar.perfplanet.com/2018/doing-differential-serving-in-2019/
- https://www.smashingmagazine.com/2018/10/smart-bundling-legacy-code-browsers/
- https://gist.github.com/samthor/64b114e4a4f539915a95b91ffd340acc
- https://github.com/philipwalton/webpack-esnext-boilerplate
- https://github.com/philipwalton/webpack-esnext-boilerplate/issues/1
Top comments (6)
Hi there! Just wanted to add that I've got Yet Another Webpack Plugin to help with this - github.com/DanielSchaffer/webpack-...
It's a little different in that it attempts to avoid the requirement of manually adding the link scripts to your template - it updates the HtmlWebpackPlugin data so that it happens automatically. There's also code to avoid emitting duplicate CSS files.
I like the idea of this, but I'm never a fan of user agent sniffing and the double download/running of code seems like a bad thing. Is this really ready for the mainstream yet?
The thing with the double download is very specific to IE11 and that one Safari version, that one safari version can be fixed with a script (standard included in the webpack-module-nomodule-plugin) but the one from IE11 seems a whole lot different. I do think that with the deprecation of IE11 coming up soon'ish we shouldn't worry about it that much but rather provide compatability to legacy and reward people using modern browsers with a quicker loading path.
That's my two cents on the matter, ofcourse we could also search for a second compat script to prevent double download.
To be honest, I should just test it with deferring the
nomodule
but haven't come to that yet.This is a good point. It's definitely a good technique to shrink bundle size and that's likely worth it alone.
I'll have a play around with it myself.
Curious to know why user agent agent detection is a problem?
It seems there is a widely used and well supported project within the node ecosystem to do this and to do it well.
npmjs.com/package/useragent
I think it is ready for mainstream. It's a matter of understanding what browsers your customers are using and trying to ship JS to specific browsers. As far a performance goes, you get some benefits of sending less code on newer browsers and the newer browsers are optimized for the new JS as well. And on the older browsers they are getting the same JS they would have got anyways.
I agree the double download could be a nonstarter but with user agent sniffing the worst case there is the current situation.
I've never been a fan of user agent sniffing because of the unpredictability of the user agents, I prefer feature detection, which was the initial intention of the two script tags.
I like that there's a community supported project in Node, but that doesn't sort things for other language ecosystems or staticly rendered sites either. I admit, that when the worst case is the fallback to ES5 transpiled code, it's not the worst thing and the potential benefits probably outweigh my concern.