Slimmer micro-services with async-define

by Maurizio Lupo

Before introducing async-define, I’d like to give some context to explain what problem it solves and why we have to deal with these kind of problems at Tes.

micro-services integration

One of the most important decisions we had to take when designing our micro-service architecture, is how to make micro-services work together. This is a particularly tricky choice, because you should choose a pattern that allows micro-services to be shipped independently and create the least amount of friction between services (and teams as per Conway’s law).

At Tes we decided to avoid the constraint of a common front-end service, by opting for integrating micro-services on a page level. The benefit is that our teams are able to develop and deploy services more independently.

micro-service page

Issues with front-end integration

When the content of a page is coming from different services, all their client side javascript shares the same execution environment (the browser), and a service can potentially clash with another.

In the simplest scenario you can solve this problem providing that every service is bundling the client side javascript, using webpack for example. Variables defined in a bundle can’t leak outside, in the global namespace, so everything is self contained and isolated. The 2 bundles can contain different and incompatible versions of the same library but everything should work as expected.

...
<script async src="mservice1.js"></script>
...
<script async src="mservice2.js"></script>

Note the async attribute in the script tags. Thanks to this attribute, the bundles will be loaded and executed asynchronously. Given that they don’t depend on one another, the order of execution doesn’t matter.

There is nothing wrong with this solution, and in many cases this is just fine, but it presents an issue when used at scale: the exact same library can be bundled several times across different bundles.

micro-service page

To solve this problem you might be tempted to adopt the simplest (and wrong) solution: externalising the common dependency (in this example, React).

<script src="react.js"></script> <!-- no async! -->
...
<script async src="mservice1.js"></script>
...
<script async src="mservice2.js"></script>

But this poses some serious issues. First of all, the common library pollutes the global namespace and creates an hard dependency between the 2 front-end bundles. They can’t be changed independently as they rely on the same version of the common library. This can be seen as a minor issue, until you have several bundle combinations spread across multiple pages, and bumping a version of a dependency requires coordinating a large number of projects. This kind of problem defeats one of the benefits of a micro-service architecture, the fact that we can ship services independently. Another big problem is performance. As you noticed you can’t load the common bundle asynchronously, because you need to ensure that the dependency is loaded and executed before the bundles. This is also a deceptively small issue, until you measure the impact of synchronous bundles in the page load.

There are 2 factors at work there:

  • synchronous javascript prevents the page from rendering until it has been parsed and executed
  • when a new connection is created the flow of data starts slowly and then gets faster. So, bytes loaded initially take more time to load

I can hear your objections: “But we are following the good old advice of placing our scripts at the bottom!” This creates more issues. It is true that it prevents the page rendering to be blocked, but it messes up the browser prioritisation and now your bundle will have to compete for the bandwidth with several images you have on the page, and the page will take much longer to become interactive.

Let’s consider a different approach.

async-define

async-define is a small function (less than half a Kb once minified) that wraps your bundle and executes it only when its dependencies have been executed. It also takes care of injecting the dependencies in the bundle, so they are never exposed globally.

Here’s an example

define(['world'], (what) => {
  console.log('hello', what) // hello world
});

define('world', () => {
  return 'world';
});

In the previous example you define a function that requires a dependency labeled “world”. Dependencies are defined with an array. Then you define a function that doesn’t require any dependency (no array argument) but defines itself with the label “world”. This function is executed immediately and also triggers the execution of the first function, now that the dependency called “world” is available.

Some seasoned developers amongst you might recognise the function signature. This is the one used by AMD (Asynchronous Module Definition). AMD was a format used for defining modules, it was very popular before the node ecosystem exploded and commonjs was widely adopted.

I am not proposing that you should use AMD here. async-define doesn’t behave like require.js (the most popular AMD library) and doesn’t take care of downloading dependencies. It just waits for the dependencies to be available: how to load them is completely up to you (the developer).

async-define plugins

How does async-define help us? You can use a plugin (available for Webpack, Browserify and Rollup) to wrap your bundle into async-define. This is an example with Webpack:

const WPAsyncDefine = require('webpack-async-define');
module.exports = {
  entry: './app.js',
  externals: {
    react: 'react-16', // this is the dependency label
  },
  plugins: [
    new WPAsyncDefine()
  ],
  output: {
    filename: 'mservice1_ad.js'
  }
};

and expose React like this:

const WPAsyncDefine = require('webpack-async-define');
module.exports = {
  entry: './react.js',
  plugins: [
    new WPAsyncDefine()
  ],
  output: {
    filename: 'react-16_ad.js',
    library: 'react-16', // library exposed with this label
  }
};

react.js will simply expose react using either commonjs or esm:

const react = require('react');
module.exports = react;

and then you are able to load the scripts like this:

<script async src="react-16_ad.js"></script>
...
<script async src="mservice1_ad.js"></script>
...
<script async src="mservice2_ad.js"></script>

Note: all scripts are async! So there are no performance problems.

Common dependency issue

There is still an issue to solve. Now that you have moved the dependency externally, you need to decide what micro-service is going to expose it. This poses a new challenge as it creates another point of “dependence” between multiple services. We came up with a solution: including the dependency in all services:

...
<!-- mservice 1-->
<script async src="react-16_ad.js"></script>
<script async src="mservice1_ad.js"></script>
...
<!-- mservice 2-->
<script async src="react-16_ad.js"></script>
<script async src="mservice2_ad.js"></script>

This might seem to be a strange piece of advice, so let me explain. When labeling a dependency, async-define ensures this dependency is executed only once. So you won’t have any problems in the execution. Still, you don’t want to load the same bundle more than once. In this case the dirty secret is: it often won’t happen. In most of the browsers, scripts with the same url are only loaded once. This is the case of Chrome, IE10+ and Safari. Firefox is the only notable exception, but in many cases a duplicated file is requested again and it receives a 304 (not modified) that costs only 1 round trip.

Here are some experiments: https://stackoverflow.com/questions/36538662/are-browsers-loading-same-scripts-multiple-times?rq=1

Updating dependencies

The benefit of being independent allows to progressively update applications without issues, except a small degrade in performance while you load multiple different versions of a dependency:

...
<script async src="react-16_ad.js"></script>
<script async src="mservice1_ad.js"></script>
...
<script async src="react-16.6_ad.js"></script> <!-- new! -->
<script async src="mservice2_ad.js"></script>

Other use cases

async-define proved to be useful also when you have to implement dynamic loading. For example if you have a project that exposes a useful feature:

const asyncDefine = require('async-define');

asyncDefine('usefulFeature-v1.0', () => {
  // a lot of code
  return usefulFeature;
});

Then, from a completely different project you can implement something like this:

const asyncDefine = require('async-define');

asyncDefine(['usefulFeature-v1.0'], (usefulFeature) => {
  // usefulFeature is available here !
});

// I load the bundle with a bit of JS
const script = document.createElement('script');
script.src = 'usefulFeature-v1.0.js' // bundle url
document.head.appendChild(script);

Webpack offers a similar functionality to dynamically import modules. But this does not work across multiple projects so it is not really an option when dealing with micro-services.

ES modules alternative

While async-define is still a good solution today, a native alternative is spreading across browsers: the es modules specification. This is worth exploring, if you don’t need to support legacy browsers. Here’s an explanation on how to use it in the browser https://developers.google.com/web/fundamentals/primers/modules

Summing up

Looking back, our decision to integrate Tes micro-services on the front-end brought many advantages, enabling us to avoid the bottleneck of a unified frontend. Together with these advantages, we also found some interesting challenges. async-define has helped us with our front-end libraries, to limit bloat, since 2015. If you, like us, face the same challenges I definitely recommend having a look at it.