John Stewart

Hi! I'm John. Software engineer, blogger, tech head, typically making or talking about software. JavaScript mainly. Android enthusiast.

Differential Serving

serve this, serve that

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>

differential-serving-example-1

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.

safari 10.1 test

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:


If you liked this article and want to say hi, then you can find me on Twitter.