DEV Community

Cover image for 8 JavaScript Techniques for Building Micro-Frontends With Webpack 5 Module Federation
Nithin Bharadwaj
Nithin Bharadwaj

Posted on

8 JavaScript Techniques for Building Micro-Frontends With Webpack 5 Module Federation

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 have spent years building large applications that multiple teams need to work on at the same time. The old way of having one giant frontend codebase leads to merge conflicts, slow builds, and painful deployments. Micro‑frontends solve this by letting each team own a small piece of the user interface. They can deploy independently, use their own framework, and move fast. But making all these pieces work together without breaking each other is hard.

Webpack 5 introduced Module Federation. It lets applications share code at runtime without duplicating it. Instead of bundling React into every micro‑frontend, you can load it once from a shared place. This keeps your downloads small and your user experience smooth. I will walk you through eight JavaScript techniques that make micro‑frontends with Module Federation practical. Each technique comes from real projects I have worked on. I will show you the code that made it happen and explain why it works.

First, you need to set up Module Federation properly. The configuration in webpack defines which parts of your application are exposed to other applications and which dependencies are shared. A remote micro‑frontend lists the components it wants to share. A host application declares where it can find those remotes. The shared dependencies should be singletons, meaning only one copy of React or Vue exists in the browser. This avoids weird state bugs.

Here is a typical remote configuration for a catalog micro‑frontend. It exposes a product list and detail component, plus a shared store. The shared block tells Webpack that React and react‑dom are singletons. The host configuration then points to the remote entry file. I usually name the host “shell” because it is the shell that wraps all micro‑frontends together.

// Remote webpack config
const { ModuleFederationPlugin } = require('webpack').container;
module.exports = {
  name: 'catalog',
  filename: 'remoteEntry.js',
  exposes: {
    './ProductList': './src/components/ProductList',
    './ProductDetail': './src/components/ProductDetail',
    './store': './src/store',
  },
  shared: {
    react: { singleton: true, requiredVersion: '^18.0.0', eager: false },
    'react-dom': { singleton: true, requiredVersion: '^18.0.0' },
  },
};

// Host webpack config (shell)
module.exports = {
  name: 'shell',
  plugins: [
    new ModuleFederationPlugin({
      name: 'shell',
      remotes: {
        catalog: 'catalog@http://localhost:3001/remoteEntry.js',
        checkout: 'checkout@http://localhost:3002/remoteEntry.js',
      },
      shared: {
        react: { singleton: true, requiredVersion: '^18.0.0' },
        'react-dom': { singleton: true },
      },
    }),
  ],
};
Enter fullscreen mode Exit fullscreen mode

The second technique is cross‑application communication. When you have separate micro‑frontends running on the same page, they need to talk to each other without being tightly coupled. I use a shared event bus and a global state store. Both are loaded as singletons so every micro‑frontend can access the same instance. The event bus allows one micro‑frontend to emit an event and another to listen for it. The store holds common data like the current user or shopping cart. In my projects, I package these utilities in a shared library called @shared/event-bus and @shared/state.

The store uses set and get methods. When a micro‑frontend updates the cart, it calls store.update('cart', updater). Subscribers immediately get the new value. I also keep a history of events so late subscribers can catch up.

class EventBus {
  constructor() {
    this.listeners = new Map();
    this.history = [];
    this.maxHistory = 100;
  }
  emit(event, payload) {
    if (this.listeners.has(event)) {
      this.listeners.get(event).forEach(callback => {
        try { callback(payload); } catch (e) { console.error(e); }
      });
    }
    this.history.push({ event, payload, timestamp: Date.now() });
    if (this.history.length > this.maxHistory) this.history.shift();
  }
  on(event, callback, replayHistory = false) {
    if (!this.listeners.has(event)) this.listeners.set(event, new Set());
    this.listeners.get(event).add(callback);
    if (replayHistory) {
      this.history.filter(h => h.event === event).forEach(h => callback(h.payload));
    }
    return () => this.listeners.get(event)?.delete(callback);
  }
}

class SharedStore {
  constructor(initialState = {}) {
    this.state = { ...initialState };
    this.subscribers = new Map();
    this.eventBus = new EventBus();
  }
  get(key) { return this.state[key]; }
  set(key, value) {
    const prev = this.state[key];
    this.state[key] = value;
    this.notify(key, value, prev);
  }
  update(key, updater) {
    const prev = this.state[key];
    const next = updater(prev);
    this.state[key] = next;
    this.notify(key, next, prev);
  }
  subscribe(key, callback) {
    if (!this.subscribers.has(key)) this.subscribers.set(key, new Set());
    this.subscribers.get(key).add(callback);
    callback(this.state[key]);
    return () => this.subscribers.get(key)?.delete(callback);
  }
  notify(key, current, previous) {
    this.subscribers.get(key)?.forEach(cb => { try { cb(current, previous); } catch(e) { console.error(e); } });
    this.eventBus.emit('stateChange', { key, current, previous });
  }
}
Enter fullscreen mode Exit fullscreen mode

Now, you do not want to download every micro‑frontend when the shell loads. That would defeat the purpose of splitting code. The third technique is dynamic remote loading. Instead of loading remotes eagerly in the webpack config, I create a RemoteLoader class that fetches the remote entry script only when the user needs it. This class also handles retries, timeouts, and health checks. I use it to lazy‑load components when a route is matched.

class RemoteLoader {
  constructor() {
    this.remotes = new Map();
    this.loadingPromises = new Map();
  }
  registerRemote(name, url) {
    this.remotes.set(name, { url, scope: name, loaded: false });
  }
  async loadRemote(name) {
    const remote = this.remotes.get(name);
    if (!remote) throw new Error(`Remote ${name} not registered`);
    if (remote.loaded) return;
    if (this.loadingPromises.has(name)) return this.loadingPromises.get(name);
    const promise = this._fetchRemote(remote);
    this.loadingPromises.set(name, promise);
    await promise;
    remote.loaded = true;
    this.loadingPromises.delete(name);
  }
  async _fetchRemote(remote) {
    const controller = new AbortController();
    setTimeout(() => controller.abort(), 10000);
    const response = await fetch(remote.url, { signal: controller.signal });
    if (!response.ok) throw new Error(`Failed to fetch ${remote.url}`);
    const script = document.createElement('script');
    script.src = remote.url;
    script.async = true;
    document.head.appendChild(script);
    await new Promise((resolve, reject) => {
      script.onload = resolve;
      script.onerror = () => reject(new Error('Script load failed'));
    });
    if (!window[remote.scope]) throw new Error(`Scope ${remote.scope} not found`);
  }
  async getComponent(remoteName, exposedModule) {
    await this.loadRemote(remoteName);
    const remote = this.remotes.get(remoteName);
    const container = window[remote.scope];
    const factory = await container.get(exposedModule);
    return factory();
  }
}
Enter fullscreen mode Exit fullscreen mode

The fourth technique is shared routing. The shell needs to know which micro‑frontend handles which path. I create a route registry where each micro‑frontend registers its routes. The shell then matches the current URL and loads the correct remote component. This allows deep links to work even if the remote hasn't been loaded yet.

const routeRegistry = {
  routes: [],
  register(routes) { this.routes.push(...routes); },
  match(path) {
    for (const route of this.routes) {
      const match = route.pattern.exec(path);
      if (match) return { ...route, params: route.paramNames.reduce((acc, n, i) => { acc[n] = match[i+1]; return acc; }, {}) };
    }
    return null;
  }
};

// In catalog micro-frontend
routeRegistry.register([
  { path: '/products', pattern: /^\/products$/, paramNames: [], remote: 'catalog', component: 'ProductList' },
  { path: '/products/:id', pattern: /^\/products\/(\d+)/, paramNames: ['id'], remote: 'catalog', component: 'ProductDetail' },
]);

// In shell: use React Router and LazyRemoteComponent
function LazyRemoteComponent({ route }) {
  const Component = React.lazy(() => loader.getComponent(route.remote, `./${route.component}`));
  return <React.Suspense fallback={<div>Loading product...</div>}><Component params={route.params} /></React.Suspense>;
}
Enter fullscreen mode Exit fullscreen mode

Fifth, CSS isolation. When teams work in separate repositories, their styles can clash. I use CSS Modules with a naming convention that includes a unique prefix per micro‑frontend. I also share design tokens as CSS custom properties. Each micro‑frontend imports a common tokens.css file and uses var(--acme-color-primary) instead of hardcoded colors. For full encapsulation, I sometimes use Shadow DOM for small widgets, but I prefer CSS Modules for the main UI because it keeps the styles accessible and not fully isolated.

// tokens.css generated from design tokens package
:root {
  --acme-color-primary: #3b82f6;
  --acme-spacing-md: 1rem;
  --acme-font-size-base: 1rem;
}

// ProductList.module.css
.list {
  display: grid;
  gap: var(--acme-spacing-md);
  padding: var(--acme-spacing-md);
}
Enter fullscreen mode Exit fullscreen mode

Sixth, testing. Testing micro‑frontends is tricky because they depend on other remotes. I write contract tests for the shared APIs – the event bus and state store must behave the same across all environments. I also write integration tests that spin up the shell and mock the remote entry points. I use Jest with a mock for window[catalogScope] that returns fake components.

// Contract test for SharedStore
describe('SharedStore', () => {
  it('should notify subscribers on set', () => {
    const store = new SharedStore({ count: 0 });
    const cb = jest.fn();
    store.subscribe('count', cb);
    store.set('count', 1);
    expect(cb).toHaveBeenCalledWith(1, 0);
  });
});
Enter fullscreen mode Exit fullscreen mode

Seventh, deployment. Each micro‑frontend should be deployable independently. I use versioned remote entry files (e.g., remoteEntry.v1.2.3.js). The shell loads a manifest that maps micro‑frontend names to the current version URL. When a new version is deployed, I update the manifest. The shell picks up the change on the next navigation. I also implement blue‑green health checks: if the new remote fails to load, the shell falls back to the previous version.

async function loadVersionManifest() {
  const res = await fetch('/manifest.json');
  return res.json();
}

async function updateRemotes() {
  const manifest = await loadVersionManifest();
  manifest.forEach(({ name, url }) => loader.registerRemote(name, url));
}

// Fallback logic
async function loadWithFallback(name, primaryUrl, fallbackUrl) {
  try {
    loader.registerRemote(name, primaryUrl);
    await loader.loadRemote(name);
  } catch {
    loader.registerRemote(name, fallbackUrl);
    await loader.loadRemote(name);
  }
}
Enter fullscreen mode Exit fullscreen mode

Eighth, performance. Shared dependencies are loaded as singletons, but you can optimize their loading order. I set eager: true for React and react‑dom so they are bundled with the shell and appear immediately. Non‑critical libraries like Lodash stay lazy. Additionally, I prefetch remote entry files based on user behavior. If a user hovers over a link to the checkout page, I load the checkout remote in the background. This makes navigation instant.

document.querySelectorAll('a').forEach(link => {
  link.addEventListener('mouseenter', () => {
    const path = new URL(link.href).pathname;
    const remotesToPrefetch = predictor.get(path);
    if (remotesToPrefetch) loader.prefetchRemotes(remotesToPrefetch);
  });
});
Enter fullscreen mode Exit fullscreen mode

These eight techniques have helped me and my teams build large‑scale applications that are both fast and maintainable. Each piece fits together: proper Module Federation config, a shared communication layer, dynamic loading, coordinated routing, safe CSS, solid tests, independent deployments, and performance tuning. When you apply these patterns, your micro‑frontends will feel like a single application even though they are built by different teams. Start small – maybe just split one part of your app and add Module Federation. Then gradually adopt the other techniques as you see the benefits. Your users will never know the difference, but your development team will thank you.

📘 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)