differential-serving

What is differential serving?

At it’s most basic level it looks something like what you see below. The idea is that you serve code to specific environments. In this case the interest is in serving ES6 code to modern browsers that support it and serve ES5 code to browsers that don’t.

This is made possible thanks to <script type="module"> and <script nomodule>. You can leverage these script properties to serve the correct JS when called by the browser.

diff-serving

Code running above can be found in examples/test.

How to create two bundles?

First, you need to create two bundles. One that targets ES5 environment (you probably already do this) and one that targets ES6 features. To do this you can use @babel/preset-env and webpack.

webpack

Full Example: webpack

This is a snippet from the example showing two webpack configs that are being exported. One contains settings for ES5 (legacy) code and the other contains settings for modern ES6 code.

webpack.config.js

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
                }]
              ]
            }
          }
        }
      ]
    },
    ...
  }
];

How to serve two bundles?

Now you need to decide how you want to serve the two different bundles. The most interesting way is letting the browser decide which bundle it should parse and execute. The other way is having the server decide based off of the user agent string that is making the request.

Browser

Full Example: webpack

Here is a small example of what the browser implementation looks:

<script nomodule src="/dist/index.legacy.js"></script>
<script type="module" src="/dist/index.esm.js"></script>

That’s really all that’s needed to get this to work. From there the browser can decide which script to load and execute. <script type="module"> contains are ES6 code and <script nomodule> works for ES5 code.

Unfortunately, this approach is not without its issues.

User Agent

Full Example: user-agent

The more manual approach is to detect the user agent string and dynamically serve the correct bundle. There is a great article written by Shubham Kanodia on Smashing Magazine that introduces a package called browserslist-useragent.

Using this you can create an express middleware to detect if you can use <script type="module"> tag or not.

index.js(server)

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 });
});

...

Then within our template we can check to see if the browser works with ESM code, if so then we serve that bundle with the <script type="module"> tag otherwise we fallback to a regular <script> tag.

main.hbs


<script type="module" src="/static/index.esm.js"></script>

<script src="/static/index.legacy.js"></script>

Tests

Time to run some tests.

Goal: Serve ES6(ESM) bundle to ES6 supported environments and serve ES5 bundles to ES5 environments. Only one bundle is to be parsed and executed.

Browsers Tested:

Browser Version Browser Test Link Browser Test Results
Chrome 73 View
Chrome 61 View
Chrome 60 View
Safari 12 View
Safari 11.1 View
Safari 10.1 View ⁉️(1)
Firefox 66 View
Firefox 60 View
Firefox 59 View ⁉️(2)
MSIE 11 View ⁉️(2)
MSEdge 18 View ⁉️(3)
MSEdge 16 View ⁉️(2)
MSEdge 15 View ⁉️(2)
iPhone XS Safari Latest View
iPhone X Safari Latest View
iPhone 8 Safari Latest View
Pixel 2 Chrome Latest View
Galaxy S9 Chrome Latest View

Issues

browser

Above contains the test results for the browser based method of serving the bundles. This is the most important one to test because the browser tries to decide which bundle to use. From the results above it’s clear there are still some issues with leaving this up to the browser.

Issues Discovered:

The worst case scenario here is not great. Unfortunately, it seems that the browser based method alone can create quite a poor user experience.

user-agent

The user agent method is a bit more contained because you are in control of which bundle is served and the worst case scenario would be serving the legacy bundle when you wanted to serve the ESM bundle. That doesn’t sound too bad given that without this approach the user would have received that bundle anyways.

It seems that the worst case scenario here still delivers a predictable and decent user experience over the browser based method.

That said, it’d be interesting to check if there are any false positives that could come up where the user agent string returns true for module support but in reality it doesn’t. In that situation, there is no user experience. 🤔

Summary

It seems that the browser based method is not the most reliable to use at the moment. If all of the browsers failed in the same way then it would be a little better but downloading both bundles seems like a nonstarter.

User Agent method seems to be the more predictable and reliable approach. Would require some good QA engineers to verify which bundle is being served in which environment but this seems to be a more manageable approach.