Last year I wrote a blog post on the journey to micro frontends we took when I worked at Peak. It was supposed to be a series, priorities changed, and the solution never evolved into what it was supposed to be. I want to revisit this and discuss why I believe it didn’t work and why I wouldn’t recommend it moving forward.

Charlie loses his mind over the Pepe Silva conspiracy

This is how I felt every time I tried to understand the solution.

When reading the article back, I presented a handful of problems that we were facing, which needed to be solved, and we did manage to solve them with this solution. The problem is that the cons outweighed the pros.

What did we get wrong?

The existing codebase didn’t have clear boundaries.

People usually want to move away from their monolith because the application has become a mess. More often than not, it’s become a mess because the application doesn’t have a clear separation of concerns.

What do I mean by this? Suppose you’ve got all your components in a massive components folder and all of your business logic dumped into another huge helpers folder. In that case, you’re going to have a nightmare eventually.

This is probably the most important thing I’ve learned from this. Structure your code well.

  • Throw away Redux; there’s way too much boilerplate. I like to put my business logic and data access code in hooks that live under the components. Reusable hooks should be moved up. Avoid huge folders of “reusable” code; they become dumping grounds.
  • Define clear verticals around your application, grouped based on product or feature. Create strict rules on importing across these verticals; only do it when necessary.
  • Avoid centralised routing and constant files. There was a file with thousands of lines of constant variables in the project I was working on. This was a nightmare for code splitting.

This is the same reason that backend monoliths often become a mess. If you can’t do this right, you’re not going to be able to do micro frontends right, either.

Module Federation locked us in to Webpack.

Let me preface this by saying Webpack is a phenomenal tool that solved the problem for us but also brought a few other issues.

  • TypeScript sort of works. By that, I mean it builds; however you don’t get the same experience in your IDE. There are a few solutions; most involve a third-party plugin, more configuration, and sometimes an extra build step.

  • This leads me to the second problem we encountered, synchronisation between the code deployed and the code people are working on in their local development environments. We often noticed code changes would be deployed, which wouldn’t work with the micro frontends already deployed.

    This resulted in runtime bugs, almost always due to small things such as contracts being out of sync, previously a build time problem. Because we can’t guarantee what build is deployed to production, we can’t ensure what types are used for local development. We need a way to synchronise the remotes with the types, which is tricky without jumping through many hoops.

  • Vendor lock-in, while Webpack and Module Federation are great tools, this solution had locked us entirely in. This has prevented us from trying interesting new technologies such as esbuild and vite.

We had a complex deployment strategy, custom Webpack plugins to determine if builds were compatible with each other, and a broken developer experience due to poor TypeScript support. Not ideal.

Revisiting the solution

So many times, I’ve heard that “we need to use micro frontends”, usually because the application isn’t structured well, has no clear boundaries, and has massive build times.

Do you need micro frontends?

This is the first thing you should be asking yourself. There are several things you can do to reduce massive build times.

  1. Use a faster tool. There are a bunch of tools on the market now which are very fast. Vite, esbuild, swc, Snowpack.
  2. Reorganise your codebase; with clear boundaries, you can avoid unnecessary rebuilds of large parts of the application. Consider splitting things out into reusable component libraries or design systems. Code splitting works a lot better, rebuild performance improves, and your codebase becomes much more maintainable.

I’ve done everything I can

Ok, you’ve done all you can to improve build performance, but it’s still too slow. The next question I’d be asking is, what kind of application are you building?

Are you building something like the AWS Console? Where are the individual products isolated? At this point, I’d consider a more traditional micro frontend approach. Deploy separate websites that share a standard header or navigation system. I’d make them entirely different bundles and include them at runtime; there are no interfaces to synchronise. It’s just a component that creates some DOM and updates for all products when released.

What about bundling multiple versions of React? Just use a CDN, have separate applications, and allow teams to update all at once or when they’re ready.

If there are no clear boundaries, or your application behaves like a single site, ditch the idea of micro frontends altogether. You likely don’t want or need to be using different technologies. The risk of bundling Vue, React 16, React 18, etc., on one page is too consequential. If you want to do this and are not worried about the performance implications, then micro frontends might be your solution.

My recommendations

The first thing is to figure out how you will split out your code. Product? Feature? Page? (maybe Page is too granular). Ensure you have clean boundaries between your features and components. Reduce how much you’re sharing to a minimum. Isolate common code.

Use a monorepo. Unless you have entirely decoupled micro frontends that share almost no dependencies and are separate applications (e.g. AWS Console), then just use a monorepo.

Try out Turborepo. For those who don’t know what this is, it’s a tool from Vercel that aims to solve the problems surrounding building monorepo solutions. We had significant success using this on some other monorepos. It’s possible to achieve remote caching to prevent the need for building everything every time. This also speeds up builds during local development. It’s tool-agnostic, too, so use whatever you want, Webpack, Snowpack, Vite, etc.

And finally, structure your code correctly, group functionality into feature folders or local packages, and enforce clear boundaries between them during code reviews or using linting rules. Ensure your engineers know the risks of coupling everything together and actively encourage decoupling and abstraction.

It’s worth noting that you’ll lose the ability to do releases without rebuilding the “whole” application. I used quotes here because Turborepo will cache assets, so you’re not actually rebuilding the things that haven’t changed, but you will have to fully release your application, like a monolith.

This solution is a good balance between solving the original problems (independently built components, clear boundaries) while retaining compile time checking.