Modern Script Loading

Serving the right code to the right browsers can be tricky. Here are some options.

Serving modern code to modern browsers can be great for performance. Your JavaScript bundles can contain more compact or optimized modern syntax, while still supporting older browsers.

The tooling ecosystem has consolidated on using the module/nomodule pattern for declaratively loading modern VS legacy code, which provides browsers with both sources and lets them decide which to use:

  

Unfortunately, it's not quite that straightforward. The HTML-based approach shown above triggers over-fetching of scripts in Edge and Safari.

What can we do?

What can we do? We want to deliver two compile targets depending on the browser, but a couple older browsers don't quite support the nice clean syntax for doing so.

First, there's the Safari Fix. Safari 10.1 supports JS Modules not the nomodule attribute on scripts, which causes it to execute both the modern and legacy code (yikes!). Thankfully, Sam found a way to use a non-standard beforeload event supported in Safari 10 & 11 to polyfill nomodule.

Option 1: Load Dynamically

We can circumvent these issues by implementing a tiny script loader, similar to how LoadCSS works. Instead of relying on browsers to implement both ES Modules and the nomodule attribute, we can attempt to execute a Module script as a "litmus test", then use the result of that test to choose whether to load modern or legacy code.

    

However, this solution requires waiting until our first "litmus test" module script has run before it can inject the correct script. This is because

What's the trade-off? preloading.

The trouble with this solution is that, because it's completely dynamic, the browser won't be able to discover our JavaScript resources until it runs the bootstrapping code we wrote to inject modern vs legacy scripts. Normally, browsers scan HTML as it is being streamed to look for resources they can preload. There's a solution, though it's not perfect: we can use to preload the modern version of a bundle in modern browsers. Unfortunately, only Chrome supports modulepreload so far.

   

Whether this technique works for you can come down to the size of the HTML document you're embedding those scripts into. If your HTML payload is as small as a splash screen or just enough to bootstrap a client-side application, giving up the preload scanner is less likely to impact performance. If you are server-rendering a lot of meaningful HTML for the browser to stream, the preload scanner is your friend and this might not be the best approach for you.

Here's what this solution might look like in prod:

   

It's also be pointed out that the set of browsers supporting JS Modules is quite similar to those that support . For some websites, it might make sense to use rather than relying on modulepreload. This may have performance drawbacks, since classic script preloading doesn't spread parsing work out over time as well as modulepreload.

Option 2: User Agent Sniffing

I don't have a concise code sample for this since User Agent detection is nontrivial, but there's a great Smashing Magazine article about it.

Essentially, this technique starts with the same `; } else { html += ` `; } response.end(html); }

For websites already generating HTML on the server in response to each request, this can be an effective solution for modern script loading.

Option 3: Penalize older browsers

The ill-effects of the module/nomodule pattern are seen in old versions of Chrome, Firefox and Safari - browser versions with very limited usage, since users are automatically updated to the latest version. This doesn't hold true for Edge 16-18, but there is hope: new versions of Edge will use a Chromium-based renderer that doesn't suffer from this issue.

It might be perfectly reasonable for some applications to accept this as a trade-off: you get to deliver modern code to 90% of browsers, at the expense of some extra bandwidth on older browsers. Notably, none of the User Agents suffering from this over-fetching issue have significant mobile market share - so those bytes are less likely to be coming from an expensive mobile plan or through a device with a slow processor.

If you're building a site where your users are primarily on mobile or recent browsers, the simplest form of the module/nomodule pattern will work for the vast majority of your users. Just be sure to include the Safari 10.1 fix if you have usage from slightly older iOS devices.

      

Option 4: Use conditional bundles

One clever approach here is to use nomodule to conditionally load bundles containing code that isn't needed in modern browsers, such as polyfills. With this approach, the worst-case is that the polyfills are loaded or possibly even executed (in Safari 10.1), but the effect is limited to "over-polyfilling". Given that the current prevailing approach is to load and execute polyfills in all browsers, this can be a net improvement.

    

Angular CLI can be configured to use this approach for polyfills, as demonstrated by Minko Gechev. After reading about this approach, I realized we could switch the automatic polyfill injection in preact-cli to use it - this PR shows how easy it can be to adopt the technique.

For those using Webpack, there's a handy plugin for html-webpack-plugin that makes it easy to add nomodule to polyfill bundles.


What should you do?

The answer depends on your use-case. If you're building a client-side application and your app's HTML payload is little more than a