Skip to content

07 - Build & Module Systems


1. Tree Shaking Internals

What: Dead code elimination for ES modules. The bundler analyzes which exports are actually imported and removes unused code from the final bundle.

How it works:

js
// math.js — exports two functions
export function add(a, b) { return a + b; }
export function multiply(a, b) { return a * b; }

// app.js — only imports add
import { add } from './math.js';
console.log(add(1, 2));

// Bundler output: multiply is removed (tree-shaken)
function add(a, b) { return a + b; }
console.log(add(1, 2));

Requirements for tree shaking:

  1. ES modules (static import/export) — NOT CommonJS (require/module.exports)

    • ES modules are statically analyzable (imports at top level, no conditional imports)
    • CommonJS is dynamic (require() can be inside if/else) — can't be tree-shaken
  2. Side-effect free modules — bundler must know that importing a module doesn't execute side effects (global mutations, polyfills, CSS imports)

json
// package.json — tell bundler this package is side-effect free
{
  "sideEffects": false
}

// Or list files WITH side effects
{
  "sideEffects": ["./src/polyfills.js", "*.css"]
}
  1. No re-exports that barrel everything:
js
// BAD: barrel file — importing ONE thing imports ALL
// index.js
export * from './Button';
export * from './Modal';
export * from './Table';
// import { Button } from './components' — bundler may include Modal and Table

// BETTER: Direct imports
import { Button } from './components/Button';

Why tree shaking fails (common cases):

  • CommonJS modules (require())
  • Side effects in module scope (code that runs on import)
  • Dynamic property access: lodash[methodName]() — bundler can't know which method
  • Barrel files with export *

2. Code Splitting Strategies

What: Breaking a single large bundle into smaller chunks loaded on demand.

Strategy 1: Route-based splitting (most impactful)

tsx
const Dashboard = lazy(() => import('./pages/Dashboard'));
const Checkout = lazy(() => import('./pages/Checkout'));
const Settings = lazy(() => import('./pages/Settings'));

// Each route is a separate chunk, loaded when the user navigates there

Strategy 2: Component-based splitting

tsx
// Heavy component loaded only when needed
const RichTextEditor = lazy(() => import('./RichTextEditor'));
const ChartLibrary = lazy(() => import('./Chart'));

function AdminPanel() {
  const [showEditor, setShowEditor] = useState(false);
  return (
    <>
      <button onClick={() => setShowEditor(true)}>Edit</button>
      {showEditor && (
        <Suspense fallback={<Spinner />}>
          <RichTextEditor />
        </Suspense>
      )}
    </>
  );
}

Strategy 3: Vendor splitting

ts
// vite.config.ts
export default defineConfig({
  build: {
    rollupOptions: {
      output: {
        manualChunks: {
          react: ['react', 'react-dom'],
          charts: ['recharts', 'd3'],
          vendor: ['lodash-es', 'date-fns'],
        },
      },
    },
  },
});
// React chunk cached separately from your app code — app changes don't bust React cache

Strategy 4: Feature flag / conditional splitting

tsx
async function loadPaymentMethod(method: string) {
  switch (method) {
    case 'card':
      return import('./payments/CardPayment');
    case 'upi':
      return import('./payments/UPIPayment');
    case 'netbanking':
      return import('./payments/NetBankingPayment');
  }
}

3. Dynamic Import Chunking

What: When you use import() (dynamic import), the bundler creates a separate chunk for the imported module and all its unique dependencies.

tsx
// Static import — included in main bundle
import { heavyUtil } from './heavyUtil';

// Dynamic import — separate chunk, loaded on demand
const { heavyUtil } = await import('./heavyUtil');

How bundlers decide chunks:

  1. Each dynamic import() creates a split point
  2. The imported module and its UNIQUE dependencies form a chunk
  3. Dependencies shared with the main bundle stay in the main bundle
  4. Dependencies shared between multiple dynamic imports get a common chunk
main.js imports: React, utils, Header
dynamic import('./Dashboard') imports: React, utils, Chart
dynamic import('./Settings') imports: React, utils, Form

Bundler output:
  main-chunk.js     → React, utils, Header (shared, always loaded)
  dashboard-chunk.js → Chart (unique to Dashboard)
  settings-chunk.js  → Form (unique to Settings)

Magic comments (Webpack/Vite):

tsx
// Named chunk
const Module = await import(/* webpackChunkName: "dashboard" */ './Dashboard');

// Prefetch (load in idle time) — low priority
const Module = await import(/* webpackPrefetch: true */ './Settings');

// Preload (load immediately) — high priority
const Module = await import(/* webpackPreload: true */ './Modal');

4. Module Federation

What: A Webpack 5 feature that lets multiple independently deployed applications share code at RUNTIME. App A can load a component from App B's live deployment.

┌─────────────────┐       ┌─────────────────┐
│    Host App      │       │   Remote App    │
│                  │       │                 │
│  import Button   │──────→│  exposes Button │
│  from 'remoteApp │  HTTP  │  at runtime     │
│  /Button'        │       │                 │
└─────────────────┘       └─────────────────┘

Configuration (Webpack):

js
// Remote app (exposes components)
new ModuleFederationPlugin({
  name: 'checkout',
  filename: 'remoteEntry.js',
  exposes: {
    './PaymentForm': './src/components/PaymentForm',
    './Cart': './src/components/Cart',
  },
  shared: ['react', 'react-dom'], // shared to avoid duplicate React
});

// Host app (consumes components)
new ModuleFederationPlugin({
  name: 'dashboard',
  remotes: {
    checkout: 'checkout@https://checkout.example.com/remoteEntry.js',
  },
  shared: ['react', 'react-dom'],
});

Usage in host app:

tsx
const PaymentForm = lazy(() => import('checkout/PaymentForm'));

function App() {
  return (
    <Suspense fallback={<Spinner />}>
      <PaymentForm amount={500} />  {/* loaded from remote at runtime */}
    </Suspense>
  );
}

How shared dependencies work:

  • Both apps declare React as shared
  • At runtime, they negotiate: "I have React 18.2, you have React 18.3"
  • The higher version is used (configurable with requiredVersion)
  • Only ONE copy of React is loaded, not two

Trade-offs:

  • Pro: Independent deployments, no monorepo needed, share code at runtime
  • Con: Runtime dependency (if remote is down, feature breaks)
  • Con: Version mismatches can cause subtle bugs
  • Con: Complex debugging (code from multiple origins)

5. Micro-Frontend Orchestration

What: Composing a web application from multiple independently developed, deployed, and maintained frontend applications.

Approaches:

Build-time composition

json
// package.json
{
  "dependencies": {
    "@mycompany/checkout": "^2.0.0",
    "@mycompany/dashboard": "^1.5.0",
    "@mycompany/admin": "^3.1.0"
  }
}

Simple but NOT truly independent — all must build together.

Runtime composition (Module Federation)

See above. Each micro-frontend deploys independently and loads at runtime.

iframe composition

html
<main>
  <nav><!-- shell nav --></nav>
  <iframe src="https://checkout.example.com"></iframe>
</main>

Maximum isolation but poor UX (no shared routing, styling, state).

Single-SPA (framework-agnostic orchestrator)

tsx
// Register micro-frontends
registerApplication({
  name: 'checkout',
  app: () => System.import('https://checkout.example.com/main.js'),
  activeWhen: '/checkout',
});

registerApplication({
  name: 'dashboard',
  app: () => System.import('https://dashboard.example.com/main.js'),
  activeWhen: '/dashboard',
});

start(); // Single-SPA manages mounting/unmounting based on route

Orchestration challenges:

  1. Routing: Shell app owns the router, micro-frontends handle sub-routes
  2. Shared state: Event bus, custom events, or shared store (keep it minimal)
  3. Styling: CSS modules / Shadow DOM / naming conventions to avoid conflicts
  4. Shared dependencies: Module Federation shared or import maps
  5. Error isolation: One micro-frontend crashing shouldn't take down others

Communication patterns:

tsx
// Custom events (loosely coupled)
// Checkout emits
window.dispatchEvent(new CustomEvent('payment:complete', { detail: { txnId: '123' } }));

// Dashboard listens
window.addEventListener('payment:complete', (e: CustomEvent) => {
  refreshTransactions();
});

Frontend interview preparation reference.