DEV Community

Cover image for Micro-Frontends Aren't Simple: Hard Lessons From Breaking Up a Monolith
Nithin Bharadwaj
Nithin Bharadwaj

Posted on

Micro-Frontends Aren't Simple: Hard Lessons From Breaking Up a Monolith

As a best-selling author, I invite you to explore my books on Amazon. Don't forget to follow me on Medium and show your support. Thank you! Your support means the world!

I remember when I first broke my monolith into pieces. It felt like freedom. My team could deploy without asking permission. We could use the latest framework without dragging the whole company along. But soon, the cracks appeared. The simpler the idea seemed, the harder the reality became. Let me walk you through what I learned, the hard way, so you don't have to.

You have one web page. Multiple teams own different parts of that page. The header comes from one server, the product grid from another, the cart from a third. Each team builds and deploys independently. That is the promise of micro-frontends. You get team autonomy, faster release cycles, and the ability to mix technologies. But here is the first hidden snag: every team ships their own version of the same libraries.

Imagine three different micro-frontends all using React. One uses React 17, another uses React 18, and the third uses a shared component library that expects React 17. The browser loads all three. You end up with multiple copies of React sitting in memory. Your JavaScript bundle size bloats. The first time I saw this, I watched the network tab fill with duplicate chunks. My beautiful fast app turned slow.

Module federation tries to fix this by sharing dependencies at runtime. It lets you specify a shared version of React, for instance. But then you hit the version conflict trap. One team upgrades React to 18. That works fine on their own. But the moment another micro-frontend loads, it expects React 17. If you use singleton mode, the first version loaded wins. If a component from the other team tries to use a feature from 18, it crashes. A silent cascade failure.

// Module federation config that looks fine but breaks later
new ModuleFederationPlugin({
  name: 'checkout',
  exposes: { './Cart': './src/Cart' },
  shared: {
    react: { singleton: true, requiredVersion: '^17.0.0' },
    'react-dom': { singleton: true, requiredVersion: '^17.0.0' }
  }
})
Enter fullscreen mode Exit fullscreen mode

I spent three days debugging a white screen that only appeared when the promotions team deployed. Their component expected React.StrictMode to work a certain way. Ours didn't. We had to force everyone to agree on exactly the same React version. So much for technology independence.

Routing turned into a nightmare. The shell owns the top-level path like /products or /checkout. But inside the products micro-frontend, I needed sub-routes: /products/:id, /products/category. Each micro-frontend has its own router, its own history push. Suddenly the browser back button jumps to places you never expected. A user clicks back once and skips two pages. They get lost. You try to sync navigation through custom events or window location changes, but that creates a tangled web of side effects.

// Shell router delegates to child
const App = () => {
  return (
    <BrowserRouter>
      <Header />
      <Routes>
        <Route path="/products/*" element={<ProductsMicroFrontend />} />
        <Route path="/cart" element={<CartMicroFrontend />} />
      </Routes>
    </BrowserRouter>
  );
};

// Products micro-frontend has its own router inside
const ProductsMicroFrontend = () => {
  return (
    <Routes>
      <Route index element={<ProductList />} />
      <Route path=":id" element={<ProductDetail />} />
    </Routes>
  );
};
Enter fullscreen mode Exit fullscreen mode

The first time I pressed back after viewing a product detail, the URL changed to /products/, but the products micro-frontend didn't know what happened. It stayed on the detail view because its internal router didn't sync with the shell. I had to create a shared history object. That meant every team had to use the same routing library and the same instance. Again, the independence fades.

Communication between micro-frontends is another hidden trap. You need the cart to know when a product is added. You use custom events. It feels easy.

// In product micro-frontend
window.dispatchEvent(new CustomEvent('product:added', {
  detail: { id: '123', quantity: 1 }
}));

// In cart micro-frontend
window.addEventListener('product:added', (e) => {
  addToCart(e.detail.id, e.detail.quantity);
});
Enter fullscreen mode Exit fullscreen mode

But what happens when someone changes the event payload? Maybe they add a variant field but forget to tell the cart team. The cart team's code doesn't crash immediately. It just ignores the variant. Later, someone relies on that variant. The bug only appears in production, when the product list micro-frontend was deployed two weeks ago and the cart team just updated. I have stories of three-hour debugging sessions that ended with a console log of event.detail missing a property. No compile-time checks. No type safety. The contract is just a string name and a guess about what fields exist.

You might think TypeScript could help. But each micro-frontend compiles separately. The type definitions are not shared at runtime. You could publish a shared types package, but then every team must update it. That is exactly the kind of coordination you wanted to avoid.

Styling was the next shock. I wanted to use CSS modules in my component. It generated a class like Button_button_3kf9. That was fine until another micro-frontend used a different button component with its own style. The global CSS leaked. My button turned blue in ways I never intended. I tried shadow DOM. It worked for isolation, but then frameworks struggled. React portals broke. I had to apply styles with ::part and ::theme, but that added complexity. Global theming became a puzzle. A dark mode toggle needed to penetrate every shadow root. I had to manually set CSS custom properties on every root node.

/* Global theme variables, but shadow DOM ignores them */
:root {
  --color-bg: white;
  --color-text: black;
}
Enter fullscreen mode Exit fullscreen mode

Instead, I had to write JavaScript to update each shadow host:

const darkMode = getComputedStyle(document.documentElement).getPropertyValue('--color-bg');
shadowHost.style.setProperty('--color-bg', darkMode);
Enter fullscreen mode Exit fullscreen mode

It worked, but it was labor intensive. Every new micro-frontend required a manual setup. I missed the simplicity of a single CSS file.

Testing became a bitter experience. I wrote unit tests for each micro-frontend. They all passed. I integrated them in a local shell. It looked fine. But in production, the order of scripts loading caused a race condition. The header micro-frontend depended on a global window.api that the products micro-frontend set up. But the header loaded first. It crashed. I bought into end-to-end tests with Cypress. They ran for twenty minutes. They flaked because of network conditions. After a week, nobody trusted them. I started writing integration tests that mocked the other micro-frontends. But then I was testing my code with fake mocks, missing real bugs.

Performance budgeting became a nightmare. A monolith has one bundle, maybe with lazy loading. You know exactly how much JavaScript you push to the client. With micro-frontends, each team optimizes their own piece. But the browser loads them all. One team adds a heavy charting library. Another pulls in a datepicker with locales. Together, they add up to 3 MB of JavaScript. The user's phone chokes. I had to create a shared performance budget per page, but that required cross-team agreement and a shared monitoring tool. The shiny independent teams concept melts into a coordinated effort again.

Deployment independence is the biggest lie. I deployed my cart micro-frontend with a new API endpoint. It required a new field in the order object. The order service expected that field. But the order service was in a separate micro-frontend that hadn't been updated. For two hours, every order failed. The rollback took fifteen minutes. I learned to always run compatibility tests before deploying. But those tests required the exact same versions of all micro-frontends running together. That meant writing a CI pipeline that pulled the latest from each team, built them together, ran integration tests, and only then allowed a deploy. This pipeline was now slower than the old monolith's deploy.

Feature flags helped. I could keep backward compatibility by supporting both old and new API shapes. But that code accumulates. One year later, I had a micro-frontend that handled three different versions of the product endpoint. It was as complex as the old monolith's conditional logic.

I found that successful micro-frontend architectures only work if you enforce strong contracts. You need a shared design system, a versioned component registry. Every component is published as a package with strict versioning. Every event payload must be defined in a shared schema, validated at runtime. You need automated tests that simulate the entire page with real micro-frontends, not mocks. You need real-time dependency conflict detection in your CI. I used a tool that scanned the lock files of all micro-frontends and reported if two of them used incompatible versions of the same library.

I started writing integration tests that spun up the actual shell and all micro-frontends using Docker. It took eight minutes to run. Every developer had to run it before merging. Initially they complained. Then they realized that the alternative was debugging production outages on Friday nights.

Here is the code for a simple integration test setup I used:

// integration/checkout.spec.js
const { spawn } = require('child_process');
const waitOn = require('wait-on');

describe('Checkout integration', () => {
  let server, shell, header, products, cart;

  beforeAll(async () => {
    // Start all micro-frontends
    header = spawn('node', ['header/server.js']);
    products = spawn('node', ['products/server.js']);
    cart = spawn('node', ['cart/server.js']);
    shell = spawn('node', ['shell/server.js']);

    await waitOn({ resources: [
      'http://localhost:3000', // shell
      'http://localhost:3001', // header
      'http://localhost:3002', // products
      'http://localhost:3003', // cart
    ]});
  });

  it('adds product to cart and shows updated count', async () => {
    await page.goto('http://localhost:3000/products/123');
    await page.click('[data-testid="add-to-cart"]');
    await page.goto('http://localhost:3000/cart');
    const cartCount = await page.textContent('[data-testid="cart-count"]');
    expect(cartCount).toBe('1');
  });

  afterAll(() => {
    header.kill();
    products.kill();
    cart.kill();
    shell.kill();
  });
});
Enter fullscreen mode Exit fullscreen mode

I also learned to monitor the boundaries. I added custom metrics in each micro-frontend for every event dispatch and every API call to another service. I logged the payload shape. When a field went missing, I saw the error immediately in Grafana instead of waiting for user complaints.

// Custom event monitoring
const originalDispatch = window.dispatchEvent;
window.dispatchEvent = function(event) {
  // Send to monitoring
  if (event.type.startsWith('micro:')) {
    fetch('/monitor/event', {
      method: 'POST',
      body: JSON.stringify({ type: event.type, detail: event.detail })
    });
  }
  return originalDispatch.call(this, event);
};
Enter fullscreen mode Exit fullscreen mode

In the end, I don't think micro-frontends are a bad idea. But they are not a shortcut to simplicity. They trade one kind of complexity for another, more distributed kind. If you have a team of five people working on a simple dashboard, you do not need micro-frontends. They will only hurt. But if you have five teams of ten, each owning a distinct business domain, and you cannot coordinate releases, then micro-frontends can help. But only if you are willing to invest heavily in contracts, integration testing, shared components, runtime validation, and performance monitoring.

I have seen teams succeed with micro-frontends. They all had strict shared libraries, a unified build pipeline for integration, and a culture of communication. They did not treat independence as an excuse to ignore the other teams. They treated it as a way to scale ownership while agreeing on the hard rules upfront.

My advice is simple. Start with a monolith. When you feel the pain of coordination, do not instantly jump to micro-frontends. First, try modularizing your code inside a single repo. Use package boundaries. Then, if you still have scaling issues, consider micro-frontends. But be ready for the hidden complexities I described. They will test your patience, your discipline, and your team's ability to work together in ways you never expected.

I still use micro-frontends today. But I do not romanticize them. I know the cost: every time I add a new micro-frontend, I add a new interface that can break. I prepare for that breakage. I write the code that catches it before users see it. And I remind myself that the monolith I broke apart was not the enemy. It was the innocent victim of my desire for independence.

📘 Checkout my latest ebook for free on my channel!

Be sure to like, share, comment, and subscribe to the channel!


101 Books

101 Books is an AI-driven publishing company co-founded by author Aarav Joshi. By leveraging advanced AI technology, we keep our publishing costs incredibly low—some books are priced as low as $4—making quality knowledge accessible to everyone.

Check out our book Golang Clean Code available on Amazon.

Stay tuned for updates and exciting news. When shopping for books, search for Aarav Joshi to find more of our titles. Use the provided link to enjoy special discounts!

Our Creations

Be sure to check out our creations:

Investor Central | Investor Central Spanish | Investor Central German | Smart Living | Epochs & Echoes | Puzzling Mysteries | Hindutva | Elite Dev | Java Elite Dev | Golang Elite Dev | Python Elite Dev | JS Elite Dev | JS Schools


We are on Medium

Tech Koala Insights | Epochs & Echoes World | Investor Central Medium | Puzzling Mysteries Medium | Science & Epochs Medium | Modern Hindutva

Top comments (0)