In large tech companies these days, it is becoming common for front-end developers to talk about “micro frontends”. MFEs are analogous to microservices in backend systems. But just like we’ve all seen bad microservice architectures that simply worsened a distributed monolith, a bad micro-frontend architecture just spreads tightly-coupled code across many moving parts.
My argument is not that MFEs never pay off their complexity. I think that for a sufficiently large team, with well factored domains, having separate pipelines within a monorepo arrangements is a reasonable design for keeping teams moving independently.
But my scepticism is that few teams are in this position, and the ones that are, should work on factoring their domains first, before adopting a more complex architecture. The many moving parts of MFEs make it much harder to release, move and test code in a coherent effort.
Instead, start with a modular monolith and do the hard work of refactoring your domains before the easy work of creating new pipelines.
A primer on micro-frontends
Micro-frontends (MFEs) are not a stupid idea. We all intuitively know that software systems are easier to evolve if they can be split into isolated parts. There’s no intrinsic reason that front-end JavaScript apps don’t benefit from the same modularisation once they reach a certain size.
Microservices are similar. The idea of microservices on the backend was originally sold as a way to make systems more scalable under load, but the real motive was that it’s easier to scale teams if each one gets its own walled context to work in, free of interference.
MFEs have history. Back in 2016 I worked on an MFE-like architecture at The Guardian, where our goal was to completely isolate the ad-tech, news and feature code, which meant that you could read the news without waiting for, say, the crosswords code to download, or the adware bloat. These architectures weren’t called anything because they were just solutions to specific technical problems.
Eventually enough companies found themselves doing this that they coined a name for it, micro-frontends, meant to evoke the intuitive benefits of microservices. The usual pattern is that instead of one JavaScript app in one repo with one pipeline, you split out N apps into N repos with N pipelines. Occasionally the apps are held in a monorepo arrangement, but that’s not universal.
Problems with MFEs
Microfrontends are very popular on the conference circuit, so I am probably about to make future job interviews awkward by criticisng them. But I’m seeing enough teams adopt MFEs at the wrong time to feel concerned about the hype. My objections boil down to three core complaints
- A monolith distributed over separate parts is a world of pain
- You need to refactor before you can split, but splitting first looks easier
- MFEs can be a form of rewrite fever
Don’t distribute your monolith!
Those of us who’ve worked on backend microservice transitions have probably witnessed an anti-pattern when instead of getting cleanly separated, independent microservices, we find ourselves with a complex hairball of dependencies now split over servers. This is dubbed a “distributed monolith” and it’s painful because the same interdependence still exists, but now it’s mediated over things like HTTP calls or events.
Most large frontend apps that would be candidates for MFEs have this level of interdependence. This leads to MFE architectures proposing some kind of context sharing or mechanism for passing around state. That could be JavaScript bundles exposing global functions or using postMessage
as an event bus. The more state that gets shared, the more dependencies that exist, the further away you are from actual isolation.
Why is this worse than having interdependencies within a monolith? Because within a monolith, you can refactor very easily. You can remove the dependency from A -> B
by changing A and B in a single commit. You can understand the dependencies by looking around a single repository. You can have TypeScript verify that nothing is using A’s method any more. You probably have a single test pipeline to verify the change.
Microfrontends have overheads if you aren’t smart about this. You can end up having to deploy code in multiple places in careful orchestration. You can end up with shared code that has to be released with an awkward versioning mechanism. You have to invent a way to integration test everything. Your engineers have to sniff around multiple repositories to understand how the whole thing fits together. That’s not a scalable developer experience.
My view on MFEs, when they exist, is that any dependencies should be carefully vetted and factored down to the absolute minimum. At The Guardian our “MFEs” didn’t share any state at all. But factoring down dependencies hard when your code is a tightly-coupled mess, which leads to the next point.
Decouple before you split
So let’s say our frontend app is a hairball that’s full of interdependencies. How do we break that link? I think engineers - and managers! - are so excited about the prospect of escaping the monolith, they forget about the intermediary stage of actually chopping up the hairball.
Refactoring any code is hard, uncomfortable, and thankless, but just like brushing your teeth or going to gym you can only avoid it so long before the consequences get to you. If you try splitting an app without decoupling its parts, one of two things happen
- You get a distributed monolith (see above)
- You manage to turn the isolated parts into MFEs (the pages with no dependencies), but the big complex pages never actually get migrated
What does good decoupling look like?
- Each part of the code belongs to a distinct domain, a particular area of the business, or it belongs to a shared “support” domain
- Each domain is, conversely, implemented in a distinct part of the code - it’s not smeared over several parts
- The parts of the code interact over a small, well-factored interface. Ideally type-safe.
You might be tempted to skip decoupling and say you’ll do it as you migrate the code. This is a bad idea. You end up in a half-migrated state where part of the code is moved but the rest can’t be decoupled. You want to minimise the scope of each incremental change you make, not try to combine refactoring and replatforming into a single mission.
MFEs can be a form of rewrite fever
Sometimes engineers (and manager) get so sick of working in a bad codebase that they fantasise about throwing the lot away. In microfrontend projects, teams sometimes feel they can use MFEs to effectively rewrite the codebase from scratch, avoiding all the complexity of the original project.
Rewrites are really dangerous. They’re tempting because writing code is much easier than reading it. Developers imagine that with modern tools and consolidated knowledge, they can do a much better job than the original engineers. There is a bit of main character syndome here (the last project was a disaster because _I_ didn’t write it). But the rewrite turns out to be more complicated than expected, because the old code was full of secrets and edge cases that weren’t obviously apparent.
My experience of rewrites is that they start great. Early on velocity seems high as you’re focused on scaffolding the new code and adding tools to the codebase. It’s when you start implementing the features that things get sticky. Rewrites are sold as everything to everyone and end up becoming a way bigger project than you first imagined.
Usually in MFEs the motiviation is a monolith codebase that’s bad enough, to make rewrites a strong temptation. But if you’ve reached that point, your pages and UI is too complex to just be rewritten in a few weeks. The Product
page that accumulates five years of code is going to take a lot of work to understand and unpick alone. Moving it into a Next.js app isn’t going to shortcut that. An incremental rewrite via MFEs is better than a total rewrite in a new monolith, but not by a lot.
Modularisation before MFEs
What is to be done, then, about frontend repositories that reach such a critical mass they can’t be worked on effectively? My advice is to start with modularisation.
Keep a single repo and build, for the moment, but start organising your code into packages with very strict architectural boundaries. Have these packages only interact via well-defined, minimal interfaces, ideally just a small user context object with basics like ID and tokens. Packages can share idempotent state like API caches but they don’t share context like Redux stores or React context.
As you split the code up, then you can implement things like Yarn workspaces that are designed for managing a monorepo. This lets you do things like split up the unit tests and only execute suites for the parts that have changed. This starts bringing in MFE-like benefits without all the overhead.
Over time you can move incrementally to a microfrontend-like architecture that sits inside a monorepo, with separate pipelines and fully differential builds.
Why a monorepo? In my experience it’s a good tradeoff between oversight and independence. You can still typecheck a monorepo app as a single unit, whilst building and deploying modules independently. Code can be shared without complex versioning and packaging; you can implement separate deployment pipelines with a little effort, but it’s much easier to move code across packages as you need to.
Oversight matters. By the time you reach this level of complexity, you need a team who are acting to oversee the overall architecture and provide common tooling. If you let every team diverge you’ll get chaos.
Refactoring _is_ hard. In one frontend I’ve worked on, with over 400,000 lines of source code (and I mean not including dependencies), I ended up writing a program just to understand the codebase itself. It used the AST (abstract syntax tree) of each file to discover when components were reading or writing Redux data, parsed that into a graph, and then let you execute queries against the graph to understand that ties between different pages. The graph data alone was hundreds of kilobytes.
But by doing this, now we could visualise the problem. Before, the argument was that we could just rewrite everything via MFEs and engineers would just kind of figure things out as they went. When we could visualise the graph of data dependencies, we could see how difficult this would be without a dedicated effort to decouple the domains in the code.
Appreciating the tradeoffs
Despite all the criticism above, I think in time this app could become something like a microfrontend project, although with more control and oversight than the polyrepo MFEs I see other organisations adopting. But it will only do so once the domain isolation and design-level refactoring is largely complete. Then the parts of the codebase will be sufficiently isolated for us to work on them independently.
You could call this microfrontends by stealth; I call it agile architecture.
Don’t take on big hero projects to implement The Grand New Architecture you’ve read about. Identify your problems, move towards them iteratively, and don’t try to skip the hard work of figuring out how to split the code into genuinely separated units, or all you’ll get is your horrible frontend monolith turned into a distributed monolith MFE.