Back to blog
micro-frontendsfrontendarchitecturescalability

Micro-Frontends: Best Practices for Scalable and Maintainable Applications

Your monolithic frontend is starting to hurt. Deployments take forever, five teams are stepping on each other's toes, and a bug fix in the checkout flow somehow breaks the navigation. Sound familiar?…

Micro-Frontends: Best Practices for Scalable and Maintainable Applications

Your monolithic frontend is starting to hurt. Deployments take forever, five teams are stepping on each other's toes, and a bug fix in the checkout flow somehow breaks the navigation. Sound familiar? That's the problem micro-frontends solve — and why teams building large, complex apps are adopting them fast.

Why Micro-Frontends Matter

The backend world figured this out years ago with microservices. Independent teams, independent deployments, independent scaling. Micro-frontends bring the same philosophy to the UI layer.

The core idea: split your frontend application into smaller, independently deployable pieces owned by separate teams. The product team owns the product catalog. The checkout team owns the cart and payment flow. The auth team owns login. Each piece can be built, tested, and deployed without coordinating with everyone else.

The payoff is real:

  • Faster deployments — ship features without a full-app release cycle
  • Team autonomy — teams choose their own tech stack and release cadence
  • Isolated failures — a broken recommendation widget doesn't take down your whole page
  • Easier onboarding — new developers work in a smaller, focused codebase
  • But it's not free. You're trading one set of problems for another. Let's talk about how to implement this well.

    How Micro-Frontends Actually Work

    There are three main integration approaches, each with different trade-offs.

    1. Build-Time Integration

    Each micro-frontend is published as an npm package, and the shell app imports them at build time.

    {
      "dependencies": {
        "@myapp/product-catalog": "^2.1.0",
        "@myapp/checkout": "^1.4.0",
        "@myapp/user-profile": "^3.0.1"
      }
    }

    This is the simplest approach, but it defeats the purpose. You still need to rebuild and redeploy the shell every time a micro-frontend changes. Avoid this for anything serious.

    2. Runtime Integration via iframes

    Old school, but genuinely useful in specific cases. Each micro-frontend lives in its own iframe. Strong isolation, works across any tech stack, no CSS or JS conflicts.

    The downsides are real though — poor UX for shared state, accessibility headaches, and performance costs. Use iframes when you're integrating a legacy app or a third-party tool you don't control.

    3. Runtime Integration via JavaScript (Module Federation)

    This is the modern sweet spot. Webpack 5's Module Federation lets micro-frontends expose components that the shell app loads at runtime — no rebuild required.

    Here's a basic setup. In your checkout micro-frontend's webpack.config.js:

    const { ModuleFederationPlugin } = require('webpack').container;

    module.exports = { plugins: [ new ModuleFederationPlugin({ name: 'checkout', filename: 'remoteEntry.js', exposes: { './CheckoutFlow': './src/CheckoutFlow', }, shared: { react: { singleton: true, requiredVersion: '^18.0.0' }, 'react-dom': { singleton: true, requiredVersion: '^18.0.0' }, }, }), ], };

    And in your shell app:

    const { ModuleFederationPlugin } = require('webpack').container;

    module.exports = { plugins: [ new ModuleFederationPlugin({ name: 'shell', remotes: { checkout: 'checkout@https://checkout.myapp.com/remoteEntry.js', }, shared: { react: { singleton: true, requiredVersion: '^18.0.0' }, 'react-dom': { singleton: true, requiredVersion: '^18.0.0' }, }, }), ], };

    Now the shell can lazy-load the checkout flow at runtime:

    import React, { Suspense, lazy } from 'react';

    const CheckoutFlow = lazy(() => import('checkout/CheckoutFlow'));

    function App() { return ( <Suspense fallback={<div>Loading checkout...</div>}> <CheckoutFlow /> </Suspense> ); }

    The checkout team deploys their update to checkout.myapp.com and the shell picks it up automatically. No shell redeploy needed.

    Communication Between Micro-Frontends

    This is where things get tricky. Micro-frontends need to share some state — the logged-in user, the cart count, the active theme. But you don't want tight coupling between them.

    Use a shared event bus. Browser custom events work well and require zero shared libraries:

    // Checkout micro-frontend dispatches an event
    window.dispatchEvent(new CustomEvent('cart:updated', {
      detail: { itemCount: 3, total: 89.99 }
    }));

    // Header micro-frontend listens for it window.addEventListener('cart:updated', (event) => { updateCartBadge(event.detail.itemCount); });

    For more complex state sharing, consider a lightweight pub/sub utility or a shared state store exposed through Module Federation. Just be careful — the more state you share, the more coupled your micro-frontends become.

    A shared auth token is fine to keep in localStorage or a cookie. All micro-frontends read it directly. No need to pass it through events.

    Practical Tips That Will Save You Pain

    Agree on a design system upfront. Nothing looks worse than three different button styles on the same page because three teams picked different component libraries. Extract your shared UI into a versioned package that everyone depends on.

    # Your shared design system
    npm install @myapp/design-system

    Handle loading and error states gracefully. A micro-frontend that fails to load shouldn't crash the whole page. Wrap remote components in error boundaries:

    class MicroFrontendErrorBoundary extends React.Component {
      state = { hasError: false };

    static getDerivedStateFromError() { return { hasError: true }; }

    render() { if (this.state.hasError) { return <div>This section is temporarily unavailable.</div>; } return this.props.children; } }

    // Usage <MicroFrontendErrorBoundary> <CheckoutFlow /> </MicroFrontendErrorBoundary>

    Pin shared dependency versions strictly. If the shell uses React 18.2 and a micro-frontend ships React 18.0, you might end up with two React instances on the page. The singleton: true flag in Module Federation helps, but you still need version alignment across teams.

    Set up a contract testing strategy. Each micro-frontend exposes an interface — props it accepts, events it emits. Use something like Pact or even a simple JSON schema to document and test these contracts. Catching interface breakage in CI is way better than catching it in production.

    Avoid over-splitting. Not every component needs to be a micro-frontend. A good rule of thumb: if two features are always deployed together, they don't need to be separate micro-frontends. Split along team and deployment boundaries, not component boundaries.

    Performance Considerations

    Micro-frontends can hurt your bundle size if you're not careful. Each remote potentially ships its own copy of shared libraries.

  • Use the shared config in Module Federation to deduplicate common libraries
  • Lazy-load micro-frontends that aren't needed on initial render
  • Monitor your Core Web Vitals per micro-frontend, not just at the page level
  • Consider server-side composition (Edge Side Includes or a BFF) if initial load performance is critical
  • Actionable Next Steps

    If you're building a new large-scale frontend or your current monolith is becoming a bottleneck, here's how to get started:

  • Map your domain boundaries. Identify which features are owned by which teams and where the natural seams are.
  • Spike Module Federation on a non-critical feature first. Get comfortable with the tooling before committing.
  • Define your communication contract — what events get emitted, what shared state exists, and where it lives.
  • Set up a shared design system before you split anything. Retrofitting this is painful.
  • Establish CI/CD pipelines per micro-frontend so teams can actually deploy independently.
  • Micro-frontends aren't a silver bullet. They add real operational complexity. But for large teams working on large apps, the autonomy and scalability they enable is worth it. Start small, validate the approach, and expand from there.