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:
// 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:
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
Side-effect free modules — bundler must know that importing a module doesn't execute side effects (global mutations, polyfills, CSS imports)
// package.json — tell bundler this package is side-effect free
{
"sideEffects": false
}
// Or list files WITH side effects
{
"sideEffects": ["./src/polyfills.js", "*.css"]
}- No re-exports that barrel everything:
// 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)
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 thereStrategy 2: Component-based splitting
// 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
// 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 cacheStrategy 4: Feature flag / conditional splitting
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.
// 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:
- Each dynamic
import()creates a split point - The imported module and its UNIQUE dependencies form a chunk
- Dependencies shared with the main bundle stay in the main bundle
- 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):
// 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):
// 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:
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
// 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
<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)
// 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 routeOrchestration challenges:
- Routing: Shell app owns the router, micro-frontends handle sub-routes
- Shared state: Event bus, custom events, or shared store (keep it minimal)
- Styling: CSS modules / Shadow DOM / naming conventions to avoid conflicts
- Shared dependencies: Module Federation
sharedor import maps - Error isolation: One micro-frontend crashing shouldn't take down others
Communication patterns:
// Custom events (loosely coupled)
// Checkout emits
window.dispatchEvent(new CustomEvent('payment:complete', { detail: { txnId: '123' } }));
// Dashboard listens
window.addEventListener('payment:complete', (e: CustomEvent) => {
refreshTransactions();
});