Siddhant Deval
Siddhant DevalAuthor
Senior Full-Stack Engineer·Sep 8, 2026·11 min read

JavaScript Modules, Chunks & Dynamic Import: The Bundler Foundation

Module Federation is not magic — it is dynamic import() with a runtime negotiation layer. Before configuring it, you must understand what a chunk is, why it lives on the network, and why two chunks sharing a library is a problem no bundler can solve alone.

JavaScript Modules, Chunks & Dynamic Import: The Bundler Foundation

A micro-frontend is not a smaller app — it is a domain boundary enforced at the deployment layer. If you can't explain the business capability it owns, you haven't drawn the boundary yet.
In Part 1, you decided that your team topology justifies a micro-frontend boundary. Now a colleague opens a tutorial on Module Federation and pastes this into your build config:
javascript
new ModuleFederationPlugin({
  name: 'checkout',
  exposes: { './Cart': './src/Cart' },
  shared: { react: { singleton: true, requiredVersion: '^18.0.0' } },
})
If you don't know what singleton: true is protecting against, you will copy this configuration correctly, ship it to production, and then spend three days debugging a broken useContext call that only fails in the composed app — never in isolation. The bug will be invisible in local development and untraceable in production logs.
This article is the foundation that makes that bug obvious before you write a single line of federation configuration. Everything in Parts 3–9 is a direct consequence of what this article explains.

1. The Module System: From Scripts to ESM

1.1 The Problem That Modules Solve

Before JavaScript had a module system, every script file was a global. If two scripts defined a function called formatDate, whichever loaded last won. Dependencies had to be loaded in a precise order. There was no mechanism for a file to declare what it needed.
The ecosystem invented workarounds: IIFE wrappers ((function() { ... })()), AMD (define/require), and CommonJS (module.exports / require()). Each solved the namespace problem differently, but all shared a fundamental limitation: they were runtime patterns, invisible to tools that needed to understand dependencies at build time.

1.2 CommonJS: Synchronous, Runtime-Resolved

Node.js popularized CommonJS, which is still the default in many codebases today:
javascript
// math.js — exporting
module.exports = {
  add: (a, b) => a + b,
  subtract: (a, b) => a - b,
}

// index.js — importing
const { add } = require('./math')  // synchronous: pauses execution until resolved
console.log(add(2, 3))             // 5
require() is synchronous and dynamic — you can put it inside a conditional, inside a loop, or build the module path at runtime:
javascript
// ❌ This is valid CommonJS — but completely opaque to a bundler
const moduleName = condition ? './feature-a' : './feature-b'
const module = require(moduleName)  // bundler cannot know which file to include at build time
Because require() is fully dynamic, a bundler that encounters it must either include every possible module (bloated output) or skip static analysis entirely (no tree-shaking).

1.3 ESM: Static, Analyzable, the Foundation of Module Federation 2.0

ECMAScript Modules (ESM) are the JavaScript standard, now supported natively in every modern browser and Node.js 12+:
javascript
// math.js — named exports
export function add(a, b) { return a + b }
export function subtract(a, b) { return a - b }

// index.js — named imports (static, must be at top level)
import { add } from './math.js'
console.log(add(2, 3))  // 5
The critical difference: import statements must appear at the top level of a module and cannot be inside conditionals or functions. This constraint is what makes ESM statically analyzable — a bundler can read any file, trace every import, and know the complete dependency graph before executing a single line of code.
Crucial Requirement
Module Federation 2.0 (@module-federation/enhanced) is ESM-first. Understanding the difference between static import and dynamic import() is not background knowledge — it is the entire conceptual foundation of how Federation works at runtime.

2. What a Bundler Actually Does

2.1 From Source to Chunk

When you run npx webpack or vite build, the bundler performs exactly four operations:
1. Entry point        →  Find src/index.tsx (your application root)
2. Dependency graph   →  Follow every import statement recursively
3. Transform          →  TypeScript → JS, JSX → JS, CSS → JS
4. Emit chunks        →  Write optimized output files to dist/
The output is not just one file. A modern bundler produces multiple chunks — separate JavaScript files, each containing a subset of your application's code:
dist/
├── main.js           ← entry chunk (app bootstrap code)
├── vendor.js         ← node_modules code (React, Router, etc.)
├── checkout.js       ← code for the /checkout route (lazy-loaded)
└── catalog.js        ← code for the /catalog route (lazy-loaded)
Each chunk is a separate network request. The browser loads main.js immediately, then fetches additional chunks on demand. This is code splitting.

2.2 Static import vs. Dynamic import(): A Network Request

Here is the most important distinction in this entire article:
typescript
// Static import — resolved at BUILD TIME
// The bundler sees this, includes Modal.tsx in the output bundle
// No network request at runtime — the code is already in the bundle
import { Modal } from './Modal'

// Dynamic import — resolved at RUNTIME
// The bundler emits Modal as a SEPARATE chunk file
// At runtime, this line makes a NETWORK REQUEST to fetch that chunk
const { Modal } = await import('./Modal')
A dynamic import() is not a function call — it is a network fetch that returns a Promise. The browser makes an HTTP request to retrieve the chunk file, evaluates it, and resolves the Promise with the module's exports. This is identical in mechanism to fetch().
typescript
// This is what React.lazy does under the hood
const CheckoutPage = React.lazy(() => import('./CheckoutPage'))
// When <CheckoutPage /> is first rendered:
// 1. Browser makes GET /dist/checkout.js
// 2. JS engine evaluates the chunk
// 3. React renders the component
Mental Model Check
Every import() is a fetch(). It has latency, it can fail, it can be cached, and it can be served from a different origin than your main bundle. Module Federation extends this model by letting the chunk come from a completely different deployment — a remote application running on a separate server.

3. Code Splitting in Practice

3.1 Route-Based Splitting

The most common use of dynamic import is splitting by route — each page is a separate chunk, loaded only when the user navigates to it:
tsx
import { lazy, Suspense } from 'react'
import { Routes, Route } from 'react-router-dom'

// Each lazy() call creates a separate chunk in the build output
const CheckoutPage = lazy(() => import('./pages/CheckoutPage'))
const CatalogPage  = lazy(() => import('./pages/CatalogPage'))
const ProfilePage  = lazy(() => import('./pages/ProfilePage'))

export function App() {
  return (
    <Suspense fallback={<PageSpinner />}>
      <Routes>
        <Route path="/checkout" element={<CheckoutPage />} />
        <Route path="/catalog"  element={<CatalogPage />} />
        <Route path="/profile"  element={<ProfilePage />} />
      </Routes>
    </Suspense>
  )
}
The build output for this app:
dist/
├── main.js          ← App shell, Router setup, PageSpinner
├── CheckoutPage.js  ← only loaded when user visits /checkout
├── CatalogPage.js   ← only loaded when user visits /catalog
└── ProfilePage.js   ← only loaded when user visits /profile
A user who only visits /catalog never downloads CheckoutPage.js or ProfilePage.js. This is the performance benefit of code splitting.

3.2 The Network Waterfall

The relationship between static and dynamic imports creates a loading waterfall:
Flow trace showing the browser network waterfall for a code-split React application. Left column shows Timeline with time markers 0ms, 80ms, 200ms, 350ms. Four horizontal bars represent sequential network requests. Bar 1 at 0ms, labeled 'GET /main.js' (cyan, longest bar, ~80ms), annotated 'Entry chunk: app bootstrap, router, shell'. Bar 2 at 80ms labeled 'GET /vendor.js' (cyan, ~120ms), annotated 'React, ReactDOM, React Router — node_modules'. Bar 3 at 200ms labeled 'GET /CatalogPage.js' (green, ~80ms), annotated 'Triggered by React.lazy() when /catalog route matches'. Bar 4 at 350ms labeled 'GET /catalog-data' (dim, ~?ms), annotated 'Data fetch begins only after chunk loads'. Right column shows State transitions: 'HTML parsed → main.js executes → vendor.js executes → route matched → lazy chunk fetched → component renders → data fetches'. A red callout at Bar 4: 'Data fetch cannot start until the chunk resolves — this is the lazy-loading waterfall'. Caption: 'Each dynamic import() is a network request that blocks rendering until resolved — understanding this waterfall is prerequisite to understanding Module Federation latency.'
Each dynamic import() is a network request that blocks rendering until resolved — understanding this waterfall is prerequisite to understanding Module Federation latency.
This waterfall has a consequence that matters for micro-frontends: a remote component cannot begin rendering — and its data cannot begin fetching — until the network request for its chunk resolves. Module Federation adds one more request to this waterfall: the federation runtime must negotiate which version of the remote chunk to load before fetching it.

4. The Duplicate Dependency Problem

4.1 What Happens When Two Chunks Bundle the Same Library

Now consider a micro-frontend scenario: two independently built applications, each with their own webpack build. Both include React as a dependency in package.json. Both bundle independently:
host-app/dist/
├── main.js    ← includes React 18.2.0 (~45KB gzipped)
└── vendor.js  ← includes React 18.2.0

checkout-remote/dist/
├── checkout.js ← includes React 18.2.0 (~45KB gzipped)
└── vendor.js   ← includes React 18.2.0
When the host app loads the checkout remote at runtime, the browser now has two separate copies of React executing in the same JavaScript environment. This is not a performance problem — it is a correctness problem.

4.2 Why Two React Instances Breaks Hooks and Context

React's hook system (useState, useEffect, useContext, useRef) depends on a module-level variable that tracks the "currently rendering fiber." There is one such variable per React instance:
javascript
// Simplified React internals — module-level state
let currentlyRenderingFiber = null

export function useState(initialState) {
  // Reads from currentlyRenderingFiber in THIS React instance
  return [currentlyRenderingFiber.memoizedState, dispatcher]
}
When the host app renders a component from the checkout remote, the remote's React instance is managing that component's fiber. But if the host's React.createContext() is called — which creates a context object in the host's React instance — and the remote's component tries to useContext() — which reads from the remote's React instance — they are talking to different registries. The context value is invisible across the boundary.
tsx
// In host app — using HOST React instance
const AuthContext = React.createContext<User | null>(null)

// In checkout remote — using REMOTE React instance
function CartButton() {
  // ❌ useContext reads from REMOTE React's registry
  // AuthContext was created in HOST React's registry
  // result is always null — no error thrown, just wrong value
  const user = useContext(AuthContext)
  return <button>{user?.name ?? 'Sign In'}</button>
}
This bug does not throw. It does not appear in React DevTools as an error. The component renders, hooks execute, and the value is simply wrong — null where a logged-in user should appear. It is the hardest class of MFE bug to diagnose because it is invisible in isolation.
Before/After split diagram illustrating the React dual-instance problem. Left panel labeled 'Without shared config (broken)' — dim background. Two separate boxes side by side: 'Host Bundle' (containing 'React Instance A' in red) and 'Checkout Remote Bundle' (containing 'React Instance B' in red). Between them, an arrow labeled 'useContext(AuthContext)' with a red X overlay, annotated 'Context lookup fails — different registries'. Below both boxes: network size indicator showing '2× React = ~90KB gzipped'. A callout in red: 'No error thrown — wrong value silently returned'. Right panel labeled 'With shared: { react: { singleton: true } } (correct)' — bright background. One box labeled 'Shared React Instance' (cyan) with two arrows pointing into it from 'Host' and 'Checkout Remote' respectively, labeled 'both resolve to same instance'. Arrow labeled 'useContext(AuthContext)' with green checkmark, annotated 'Single registry — correct value'. Network size: '1× React = ~45KB gzipped'. Caption: 'Two React instances in one browser tab is a silent correctness bug — hooks work, but cross-boundary context is invisible.'
Two React instances in one browser tab is a silent correctness bug — hooks work, but cross-boundary context is invisible.
Performance / Safety Warning
The duplicate React instance bug does not appear in unit tests, integration tests, or Storybook. It only manifests when the host app and remote are both running and the remote component consumes a context created in the host. Plan for integration tests that test this boundary explicitly — Part 7 covers this.

5. The Module Runtime: What the Federation Runtime Injects

5.1 import.meta and the Module Scope

ESM introduces import.meta — an object that contains metadata about the current module. In a browser:
javascript
console.log(import.meta.url)
// "https://example.com/dist/checkout.js"

console.log(import.meta.env)
// { MODE: 'production', BASE_URL: '/', ... }  (injected by Vite/webpack)
The federation runtime uses import.meta to inject coordination metadata into each chunk — which version it is, which modules it exposes, which shared dependencies it requires, and where to find the remote manifest.

5.2 What the Federation Runtime Injects

When you use Module Federation, webpack injects a runtime bootstrap into each chunk:
javascript
// Simplified view of what webpack's federation runtime adds to each chunk
// (this code is generated — you do not write it)
__webpack_share_scopes__.default = {
  react: {
    '18.2.0': {
      get: () => () => require('react'),
      loaded: true,
      eager: false,
    }
  }
}

// Before rendering any federated component, the runtime negotiates:
// "Does the host already have react@18.2.0 loaded? If yes, reuse it."
// "If not, load my own copy — but mark it in the shared scope."
This negotiation is what singleton: true controls. Without it, the runtime uses the first-loaded version but does not prevent a second instance from loading. With singleton: true, it enforces that only one instance can ever exist in the shared scope — a second loader finds the existing instance and uses it.
Architectural Note
You do not need to understand the webpack runtime internals to use Module Federation correctly. But understanding why the shared configuration exists — to coordinate the module registry across independently loaded chunks — makes every configuration decision in Part 3 mechanical rather than cargo-culted.

6. Recognising the Monolith Ceiling

Coming from a React SaaS monolith, the transition to micro-frontends is triggered by specific, observable pain points. These are the signals that the coordination surface has grown large enough to justify runtime composition:
SignalWhat It Looks LikeThe MFE Answer
Deployment couplingCheckout team's broken test delays Profile team's releaseIndependent deployment pipelines per bounded context
Chunk size regressionOne team's added dependency bloats the global vendor bundleEach remote bundles its own non-shared dependencies
Context bleedTwo teams modifying the same AuthContext provider create race conditionsShell owns auth; remotes consume via event/cookie (Part 6)
Local dev complexityRunning the full app requires starting 5 servicesRemote override pattern: point locally to staging remotes (Part 9)
Type contract driftA remote's exported component's props change and the host silently breaks@module-federation/dts-plugin type bundles (Part 4)
Pro Tip & Optimization
If you are reading this from a monolith that has not yet hit these signals, do not adopt Module Federation preemptively. The correct path is: monolith with module boundaries → monorepo with affected builds → runtime composition when the coordination pain is measurable. Skip steps only when the signals are already present.

Summary

ConceptRule
Static importBuild-time — code is included in the bundle, no runtime request
Dynamic import()Runtime — a network request; returns a Promise; can fail
Code splittingEach import() creates a separate chunk file in the build output
Duplicate React instanceSilent correctness bug — context invisible across instances, no error thrown
Module Federationshared config coordinates the module registry across independently loaded chunks
Singleton requirementsingleton: true prevents a second React instance from loading in the shared scope

What's Next

In Part 3, the foundation built here becomes operational. We configure ModuleFederationPlugin, trace exactly what happens when a host calls import('remote/Cart'), and solve the singleton problem through shared configuration. The mental model from this article transforms Module Federation from a black box into a predictable system. Part 3 → Module Federation Core

References

  1. ECMAScript Modules — MDN
  2. Dynamic import() — MDN
  3. Webpack — Code Splitting
  4. Vite — Code Splitting
  5. import.meta — MDN
  6. Module Federation — Shared Modules
Research & Synthesis Note

This article was developed with AI-assisted deep search, specification cross-referencing, and technical research synthesis.

#JavaScript Modules#Webpack#Code Splitting#ESM#Bundlers#Micro-Frontends
Siddhant Deval

Written by Siddhant Deval

Senior Full-Stack Engineer building high-scale architectures, browser performance engineering systems, and SaaS platforms.