Microfrontends in Practice: Deploying Features Without the Monolith

SS Saurav Sitaula

With our Webpack Module Federation setup, we gained a superpower: independent deployments. The Cart team could deploy on Tuesday, and the Product team on Friday. No coordination meetings. No monolithic release trains. Here's how we orchestrated our dynamic remotes.

The Nightmare of the Release Train

Before we moved our e-commerce app to microfrontends, deploying felt like launching a space shuttle.

We had a “Release Train.” If the Product Catalog team had a critical bug fix, they had to wait for the Checkout team to finish their QA cycle. If a test failed in the Shopping Cart component, the entire release was blocked.

Dozens of engineers, handcuffed together by a single Webpack build.

When we broke our architecture into a host-app, a product-app, and a cart-app using Webpack 5 Module Federation, the primary goal wasn’t just performance—it was autonomy. We wanted teams to deploy when they were ready, independently.

Independent Deployments: The “Aha” Moment

In our new architecture, the cart-app is its own repository. It exposes a single entry point via Webpack:

// cart-app/webpack.config.js
new ModuleFederationPlugin({
  name: "cartApp",
  filename: "remoteEntry.js",
  exposes: {
    // Expose the entire Cart drawer
    "./CartDrawer": "./src/components/CartDrawer"
  },
  shared: {
    react: { singleton: true, requiredVersion: "^18.0.0" },
    "react-dom": { singleton: true, requiredVersion: "^18.0.0" },
  }
})

The host application simply imports it lazily:

const RemoteCartDrawer = React.lazy(() => import('cartApp/CartDrawer'));

Because this import happens at runtime, not build time, the cart-app can be built and pushed to an S3 bucket (or Vercel, or Netlify) completely independently.

The next time a user navigates to our store, their browser fetches the newly deployed remoteEntry.js file from the cart-app URL. The host-app didn’t have to rebuild. The product-app didn’t have to rebuild. The release train was dead. We were flying solo.

Handling the URLs (The Orchestration Problem)

Independent deployments sound great until you try to test them across different environments.

How does the host-app know where to find the cart-app? Locally, it might be on localhost:3002. In QA, it’s on a staging domain like qa.cart.store.com. In production, it’s on the main domain.

If you hardcode cartApp@http://localhost:3002/remoteEntry.js in your Webpack config, you can never deploy it to production.

The trick is injecting the remote URLs dynamically at build time based on environment variables.

The Dynamic Remote Config

// host-app/webpack.config.js
const { ModuleFederationPlugin } = require("webpack").container;

// Read the environment variable during build
const isProd = process.env.NODE_ENV === 'production';
const isQA = process.env.ENV === 'qa';

// Set default fallback to localhost
let cartUrl = 'http://localhost:3002';
let productUrl = 'http://localhost:3001';

if (isProd) {
  cartUrl = 'https://cart.store.com';
  productUrl = 'https://products.store.com';
} else if (isQA) {
  cartUrl = 'https://qa.cart.store.com';
  productUrl = 'https://qa.products.store.com';
}

module.exports = {
  plugins: [
    new ModuleFederationPlugin({
      name: "hostApp",
      remotes: {
        // Inject the variable into the remote string
        cartApp: `cartApp@${cartUrl}/remoteEntry.js`,
        productApp: `productApp@${productUrl}/remoteEntry.js`,
      }
    })
  ]
};

This simple if/else block allowed our CI/CD pipelines to build the exact same codebase but target the correct remote environment based on where it was deploying to.

Versioning for Cache Busting

Browsers cache JavaScript files aggressively. If the Product team deployed an urgent fix to a button color, they didn’t want users seeing a cached remoteEntry.js file for 24 hours. They needed to guarantee that the host app fetched the new code immediately.

Instead of deploying to https://products.store.com/remoteEntry.js, we tied our deployment path to the package.json version of the product-app.

// In product-app's deployment script (pseudo-code)
const version = require('./package.json').version; // e.g., "1.4.2"
deployToS3(`s3://product-app-bucket/v${version}/`);

Then, in the host app, we update the Webpack config to point to that versioned folder:

// host-app/webpack.config.js
const productVersion = process.env.PRODUCT_APP_VERSION || '1.4.2';

const remotes = {
  productApp: `productApp@https://products.store.com/v${productVersion}/remoteEntry.js`
};

When the Product team bumped their version from 1.4.2 to 1.5.0, they told the Host team to update the PRODUCT_APP_VERSION environment variable. The remote URL changed. The browser was forced to fetch the new path, bypassing the cache. Predictable updates. No stale code.

The Developer Experience

You might be wondering: if these are all independent repos, how do you actually run the thing locally? Do you have to open three terminal tabs, clone three repos, and start them one by one?

We did that for a week. It was miserable.

To fix it, we created a root orchestration layer. Even though the apps deployed independently, we kept them together in a monorepo structure using a tool like Lerna, Turborepo, or just NPM Workspaces.

We added a root package.json with a script to boot everything at once:

{
  "name": "store-monorepo",
  "scripts": {
    "start:host": "npm run start --workspace=host-app",
    "start:product": "npm run start --workspace=product-app",
    "start:cart": "npm run start --workspace=cart-app",
    "start:all": "npm-run-all --parallel start:*"
  }
}

Run npm run start:all, and everything boots up locally, talking to each other on different localhost ports.

What I Wish I’d Known Earlier

  1. Independent pipelines mean more CI/CD maintenance. Instead of one Jenkinsfile or GitHub Action workflow, we had one for every microfrontend. The deployment logic got duplicated. We eventually moved to shared pipeline templates to stay sane.
  2. Runtime errors replace build-time errors. If the Cart app fails to deploy, and the Host app tries to load cartApp@https://..., it throws a runtime exception. You must wrap your remote imports in React Error Boundaries (<ErrorBoundary><Suspense><RemoteCart /></Suspense></ErrorBoundary>) to catch failed network requests.
  3. Communication is key. Decoupling the codebase doesn’t mean decoupling the teams. If the Host app expects the Cart app to accept an isOpen prop, and the Cart team renames it to showDrawer, the host will silently break at runtime because TypeScript doesn’t natively type-check across remote network boundaries.

Decoupling our codebases unblocked our teams. The autonomy was worth the architectural complexity.

But soon we hit a wall. When a user clicks “Add to Cart” inside the product-app, how does the cart-app know to update its total? They are completely separate Webpack builds. Passing props wasn’t going to work.

We needed a way for them to talk without coupling them back together.


P.S. — The first time I deployed a bug fix to the shopping cart, and saw it live in production two minutes later without taking down the main app, I felt like a hacker. True CI/CD is intoxicating.

SS

Saurav Sitaula

Software Architect • Nepal

Back to all posts
Saurav.dev

© 2026 Saurav Sitaula.AstroNeoBrutalism