Micro front-ends at Onfido: The component chimera

Building a modern webapp in a way which stands the test of time is challenging. Lacking a TARDIS, accurately predicting shifts in the front-end landscape over the comings years is almost impossible. In 2018 React rules the roost, and Redux is pretty hot on the state management side, but there’s no guarantee that’ll still be the case in the far flung future of 2019.

Hyped approaches and libraries aren’t destined to stick; some inevitably end up mere fads. Despite this, the front-end ecosystem moves at a breakneck pace all the same. It really wasn’t that long ago since ‘jQuery all the things’ was the de facto standard.

The march to codebase chaos

The breakneck pace of the front end world can be troublesome when coupled with the need for multiple teams to work on large webapps with lifespans of years. Every team has their own priorities and preference for when to adopt a given new framework or approach. Without strong, potentially even stifling, conventions it’s easy for your codebase to transform from oh-so-clean-and-elegant to dumpster fire as new approaches are crammed in, jostling with the five previous ‘best practices’ from the past few years. Your new hire process transforms into a solemn look and handover of the ceremonial fire extinguisher (he’s called Fred). Chaos reigns.

One approach is to craft something that just works™, doggedly stick to it, and chastise all these yung ‘uns for their love of the Reacts and the Vues. After all, productivity and features matter way more to your customers than if the product was written using amazeballs.js instead of jQuery. However, the cumulative benefits of new tooling and frameworks over the course of years (especially in the front-end world) erodes the productivity advantages of even the best architectures. Stellar UI rendering approaches from 10 years ago are still likely to prove more difficult to work with when rendering complex UIs than React. However, throwing away all that good stuff (potentially years upon years of engineering effort and testing in real world scenarios) just because we’ll be faster at building new stuff once we’ve rewritten everything is a hard sell.

Either way is painful: sailing the good ship obsolesce into the sunset, or trying to rebuild the whole damn thing, avoiding the introduction of bugs as you do so. After all, no matter how clean and elegant your new codebase is, all that users will care about is that they now can’t share their cat memes, or that scrolling turns their laptop into a fantastic approximation of a grill. The small things.

Releasing a rewritten application.

In an ideal world we should be able keep our completed features; leave them be unless there’s a reason to revisit them beyond ‘they old, yo’. At the same time embracing the productivity gains new tools can yield. Build new things in new ways, keeping them distinct from the old. This is the core goal of the micro front-end (MFE) approach at Onfido; build features once, making sure they remain usable for as long as possible, with as little maintenance as necessary. There’s a number of aspects to this, but in this post we’ll start off with what we found to be the trickiest to get working: components!

Mixing it up with components

Let’s start with a nice demo!

Stitching together frameworks on a weekend. I’m fun at parties!

In this demo there is a React 16 ‘host’ application with three MFEs (Angular, Vue, and React 16). The MFEs are all pretty simple; they have a counter in local state (which will start ticking when they mount), and receive a counter prop from React host application. The host app will also hide and show the MFEs when they are clicked on, resetting their counters in the process.

The host app does not know anything about how these individual MFEs are implemented. In fact, all it sees are React components like any other:

What is this dark, evil magic?!

The secret sauce behind this abstraction is the component adapter. This adapter provides the foundation for any application to build upon, wrapping that foundation in a nice, familiar, component that the host app will understand. A common theme across frameworks new, old, and future (hopefully, seeing as this what approach is banking on!) is that they all interact with the DOM in some way when mounting. We often don’t interact with the DOM directly nowadays in front-end applications, instead preferring abstractions such as templates or JSX, but we still pass a DOM element reference to the application so it knows where to set up shop.

Each framework handles the process slightly differently. React, for example, uses ReactDOM to render the component tree:

ReactDOM.render(<SomeApplication />, someDOMElementRef);

and Vue allows you to specify the reference for the root element in the Vue instance:

var app = new Vue({
el: someDOMElementRef,
})

This approach, of using DOM element references, is consistent across modern front end component frameworks, vanilla JS, and even ‘old school’ approaches such as jQuery— if you’re able to provide a DOM element, you can mount an application. It may not have always have been the most natural way to go about it (especially in the jQuery heavy days), but it’s always been possible.

The interface of the adapter itself is simple:

  • onMount: What to do when the MFE should mount. It receives the DOM element, and we use that to mount the MFE.
  • onUnmount: What to do when the MFE should unmount. Always good to clean up after yourself. No-one likes a memory leaker.
  • onUpdate: What to do when the props passed to the adapted component change. In the demo above all of the MFEs use the updated counter value to drive a change in the UI.

These three hooks allow us to take an MFE written in any framework and, using an adapter, create a component that can be seamlessly integrated with another framework (including different versions of the same framework)!

This isn’t always smooth sailing; complications may arise depending on the implementation of your MFE. Angular in particular is not a framework built around the idea of external data being passed into it as a simple prop (unlike Vue and React where conceptually any component can be the root of an application). In these scenarios a little creativity (e.g. reaching for an event channel or observable that is used by the mounted MFE) is required to save the day.

Thankfully such day-saving is only needed once for a given MFE approach (so possibly worth a rain-check on the cape). Once the approach for, say, adapting a Vue MFE has been nailed down, replicating it for other Vue MFEs is easy. As an added bonus, the consistent interface for the different adapters means it’s trivial to expose any given MFE as any component we have an adapter for. This buys us a ton of engineering freedom for innovation, as we don’t have to be concerned quite so much about how to integrate new approaches and frameworks with the old when creating new MFEs.

Still, as awesome as all this is, it’s worth bearing in mind that just because you can, doesn’t mean you always should! So it’s time to explore…

The dangers

Having different component libraries playing nice is great, but the resulting freedom can be intoxicating. Little bit of Preact here, a dab of Vue there, and just a smidge of Ember , what can go wrong? If it all works together, there’s no problem!

After the introduction of the 27th new framework this week

Unless you need to work as a team, that is. Having multiple frameworks in circulation for smaller teams can be burdensome. Whilst common component libraries such as design systems can be adapted in the same way as MFEs, every engineer venturing off with their favourite flavour of the month is a recipe for disaster, restricting the potential for shared learning to help drive the creation of great products, and for engineers within the same team to work together efficiently.

Instead of the free for all approach, adapters are much better suited to the middle ground of giving teams the freedom to choose their tools. Having that level of freedom is highly beneficial. The greater exposure to the myriad approaches in the front-end ecosystem can aid with the spread of ideas within the greater engineering department, fuelling beneficial debates around approaches that may have otherwise been accepted at face value if the wider team only ever works and researches within a smaller slice of the ecosystem.

Performance considerations

Naturally, these additional frameworks and adapters don’t come for free (wouldn’t it be great if they did? performance analysis would be easy). The runtime impact of spinning up an MFE, depending on how it was implemented, can be significant. The potential to mount and unmount as necessary means that lightweight frameworks can fare significantly better than those that are a little heavier and all encompassing.

With a 6x CPU slowdown (in Chrome devtools) on an i7–7500U, mounting the Angular MFE in the demo above takes a whopping ~100ms. By comparison the Vue and React MFEs both performed significantly better, clocking in consistently under 2ms to mount and render. Production grade versions of all the frameworks used in the demo will perform better to a greater or lesser degree, but if the product is designed to run in a CPU constrained environment these are the details that require further scrutiny when deciding the implementation of an MFE.

There’s also the network impact to consider. Every different framework or major version thereof requires an additional dependency to be downloaded by the end user. React is around 30kB gzipped, with Vue weighing in at 20kB, and Angular again trailing a little at a meatier ~110kB. As a result, optimisations for serving modern webapps efficiently (code splitting, lazy loading, compression etc.) gain increased importance in the MFE approach. If the application has extremely strict requirements for network usage, the more ‘lax’ approach to dependencies that MFEs necessitates to be most effective (e.g. potentially having two major versions of React lazily loaded in as the user navigates around versus only one) can be problematic.

The running theme: for extremely lean and performant applications, the MFE approach poses potential pitfalls which need to be weighed up against the engineering benefits. Detailed monitoring to inform optimisations is critical to ensuring that users don’t encounter noticeable performance impacts. For larger applications and those with more typical (or even beautifully permissive performance targets like traditional desktop users for an internal system) the impact of additional dependencies and the adapter layer is much less pronounced. It’s almost certainly the rest of the application eating up the lions share of CPU and network resources. As always with performance, measuring and understanding both the typical user’s runtime environment, along with the state of your own application, is paramount.

In summary

Whilst there are pitfalls to be wary of, this approach has been a hugely positive step in allowing us to work independently as teams whilst still producing beautifully seamless webapps as the end product. However, the component layer, whilst the most technically interesting (to me, at least!) is not the only piece of this puzzle. I’ve completely sidestepped styling, dependencies, and how ‘global’ state works in the MFE world; they’re all topics for another time!

So, for now, I hope you’ve enjoyed this brief foray into how we’ve pushed the component model to build large, scalable, front end applications at Onfido!