Code splitting loads less JavaScript up front: a dynamic import() becomes a separate chunk that the browser fetches only when it is needed, and React.lazy with a Suspense boundary renders that chunk as a component, with a fallback while it loads. Split routes first, then heavy components that most users never open, and preload chunks on intent so splitting does not add waits. Module federation solves a different problem: separately built and deployed apps load each other's modules at runtime and share one copy of React. Use it when teams must release independently, not to make a single app smaller.
Frontend interview practice question
How do you code split a React app, and when do you need module federation instead?
Interview quick answer
Interview focus
This React interview question tests whether you can explain Code Splitting vs Module Federation: React, connect it to production trade-offs, and handle common follow-up questions.
- Code Splitting vs Module Federation: React explanation without falling back to memorized definitions
- Lazy Loading and Performance reasoning, edge cases, and production failure modes
- How you would answer the most likely React interview follow-up
Use this React interview question to rehearse a quick answer, common mistake, follow-up, and production pitfall.
Full interview answer
The scenario to explain
A shopping app ships one 1.8 MB JavaScript bundle. It contains the storefront every visitor needs, plus an admin dashboard, a charting library and a rich-text editor that only a few users ever open. Mobile users wait for all of it before the first interaction works. The interviewer asks how you would split it, then adds a twist: two other teams want to ship the cart and the account pages on their own release schedules. The first question is about code splitting. The second is about module federation, and treating them as one tool is the common mistake.
Code splitting in one paragraph
A static import puts a module in the same bundle as its importer. A dynamic import() is a split point: the bundler (webpack, Vite, Rollup or esbuild) emits the module and its dependencies as a separate chunk and returns a promise that resolves when the chunk has loaded. React.lazy turns that promise into a component, and the nearest Suspense boundary shows its fallback until the code arrives. React calls the loader once, on the first render, and caches both the promise and the loaded component.
import { lazy, Suspense } from 'react';
import { Routes, Route } from 'react-router';
// Declare lazy components at module top level, never inside a component:
// a new lazy() on every render remounts the subtree and resets its state
const Storefront = lazy(() => import('./routes/Storefront'));
const AdminDashboard = lazy(() => import('./routes/AdminDashboard'));
// React.lazy needs a default export, so map a named export to default
const SalesChart = lazy(() =>
import('./charts').then((module) => ({ default: module.SalesChart }))
);
export function App() {
return (
<Suspense fallback={<PageSkeleton />}>
<Routes>
<Route path="/" element={<Storefront />} />
<Route path="/admin" element={<AdminDashboard />} />
</Routes>
</Suspense>
);
}
export function SalesPanel() {
return (
<Suspense fallback={<ChartPlaceholder height={320} />}>
<SalesChart />
</Suspense>
);
}
Split here | Why it pays | Watch out for |
|---|---|---|
Routes | Each page's code is needed only on that page; the biggest and safest win | Keep the app shell and the landing route in the main bundle |
Heavy, rarely opened UI | Editors, charts, maps, admin tools and modals stay out of the first load | Reserve the space so the fallback does not shift the layout |
Below-the-fold sections | Their code can wait until the section nears the viewport | Start loading early enough that users never see the fallback |
Libraries used by one action | A PDF or spreadsheet export library can load inside the click handler | Show progress, because that first click now includes a download |
Tiny components | Almost nothing: each chunk is one more request and one more loading state | Over-splitting creates waterfalls and flicker |
Production pitfall: waterfalls and fallback flashes
A lazy chunk starts downloading only when React first tries to render it. If that chunk renders another lazy component, the second request waits for the first one: a waterfall. Start the import early on intent instead, such as hover, focus or the route a user is likely to open next; the browser loads each module once, so the later render reuses the download. Navigations need one more fix. When an update inside startTransition suspends, React keeps the current screen instead of replacing already visible content with the fallback, and Suspense-enabled routers are expected to wrap navigations in transitions.
import { lazy, Suspense, useState } from 'react';
const loadEditor = () => import('./RichTextEditor');
const RichTextEditor = lazy(loadEditor);
// Warm the chunk on intent; if this fails, the render path retries
// and reports the error through the nearest error boundary
const preloadEditor = () => {
loadEditor().catch(() => {});
};
function ReplyBox() {
const [open, setOpen] = useState(false);
return (
<>
<button onMouseEnter={preloadEditor} onFocus={preloadEditor} onClick={() => setOpen(true)}>
Write a reply
</button>
{open && (
<Suspense fallback={<EditorPlaceholder />}>
<RichTextEditor />
</Suspense>
)}
</>
);
}
Production pitfall: chunks that disappear after a deploy
Chunk file names carry content hashes. A user who opened the app before a deploy still runs the old main bundle, and that bundle asks for the old hashes. If the host has already removed the previous deploy's files, the request fails with a ChunkLoadError in webpack or "Failed to fetch dynamically imported module" in Vite builds, and React.lazy throws that error to the nearest error boundary. Wrap lazy areas in an error boundary with a reload action, keep the previous deploy's assets available for a while, and serve the HTML with Cache-Control: no-cache so new visits always get the new file names. Vite also fires a vite:preloadError event on window for this case.
import { Component, Suspense } from 'react';
class ChunkErrorBoundary extends Component {
state = { failed: false };
static getDerivedStateFromError() {
return { failed: true };
}
render() {
if (!this.state.failed) return this.props.children;
return (
<div role="alert">
<p>This part of the page could not load. A new version may be available.</p>
<button onClick={() => window.location.reload()}>Reload</button>
</div>
);
}
}
export function AdminArea() {
return (
<ChunkErrorBoundary>
<Suspense fallback={<PageSkeleton />}>
<AdminDashboard />
</Suspense>
</ChunkErrorBoundary>
);
}
Module federation solves a different problem
Code splitting divides one build. Module federation connects several builds at runtime. Each remote app builds and deploys on its own and publishes a small container entry, conventionally remoteEntry.js, that exposes chosen modules. A host lists the remotes it consumes and imports their modules like local ones, but the code is fetched from the remote's own deployment when it is needed. Shared dependencies are negotiated at runtime, so the host and every remote can use one copy of React. Webpack 5 ships this as ModuleFederationPlugin, and Module Federation 2.0 extends the same model with a runtime, manifests and type hints for webpack and Rspack.
// cart team: webpack.config.js (a remote)
const { ModuleFederationPlugin } = require('webpack').container;
module.exports = {
// ...
plugins: [
new ModuleFederationPlugin({
name: 'cart',
filename: 'remoteEntry.js',
exposes: { './CartPage': './src/CartPage' },
shared: {
react: { singleton: true, requiredVersion: '^19.0.0' },
'react-dom': { singleton: true, requiredVersion: '^19.0.0' },
},
}),
],
};
// shell team: the host plugin entry
new ModuleFederationPlugin({
name: 'shell',
remotes: { cart: 'cart@https://cart.example.com/remoteEntry.js' },
shared: {
react: { singleton: true, requiredVersion: '^19.0.0' },
'react-dom': { singleton: true, requiredVersion: '^19.0.0' },
},
});
// shell app code: the module loads at runtime from the cart team's deploy
const CartPage = lazy(() => import('cart/CartPage'));
The bootstrap boundary
A federated host usually moves its startup code behind one dynamic import: the entry file contains only import('./bootstrap'), and bootstrap.js renders the app. That async boundary gives webpack time to load the remote entries and agree on shared versions before any component code runs. Without it, the app fails at startup with "Shared module is not available for eager consumption". Marking a shared module as eager also removes the error, but then that module is always downloaded with the initial chunk.
Question | Code splitting | Module federation |
|---|---|---|
Problem it solves | Too much JavaScript on the first load | Independent builds and deploys for parts of one product |
Builds and deploys | One build, one deploy, many chunks | Several builds, each deployed on its own schedule |
Shared libraries | The bundler deduplicates them inside one build | Negotiated at runtime through shared, singleton and requiredVersion |
Typical failure | A chunk from the previous deploy is gone | A remote is down or ships an incompatible version |
Who needs it | Almost every large single-page app | Organizations where several teams own one user-facing product |
Trade-offs of module federation
Independent deploys come with runtime coupling. A broken or slow remote now breaks a region of the host in production, so wrap every remote in an error boundary with a fallback. Version skew becomes a runtime problem: a singleton keeps one copy of React, but a remote built against an incompatible version only gets a console warning unless strictVersion is set. Two copies of React, from a missing singleton or a wrong shared config, break hooks with React's Invalid hook call warning. Local development, integration tests and type sharing all get harder. If one team owns the whole app, versioned packages or a monorepo with code splitting give most of the benefit without the runtime risk.
Follow-up questions
Does React.lazy work with named exports? Not directly. The promise must resolve to a module whose default export is a component, so map the named export to default, as in the chart example.
How is this different from tree shaking? Tree shaking removes exports that nothing imports, at build time. Code splitting keeps code that is used but delays loading it. The two work together.
Does it work with server rendering? Yes, with React 18's streaming renderers: lazy components inside Suspense render on the server, and the client needs their chunks before it can hydrate those boundaries. Frameworks such as Next.js split by route automatically.
How do you know it worked? Compare a bundle report (webpack-bundle-analyzer or rollup-plugin-visualizer) before and after, check the Coverage panel in Chrome DevTools for unused JavaScript on the first route, and watch LCP and INP in field data.
Interview framing
Say it like this:
"Code splitting is about loading less up front. Every dynamic import becomes a chunk, React.lazy renders it behind a Suspense fallback, and I split routes first, then heavy components most users never open. I preload on intent so splits don't add waits, make navigations transitions so fallbacks don't flash, and wrap lazy areas in error boundaries because chunks can vanish after a deploy. Module federation is a different tool: separately deployed apps load each other's modules at runtime and share one React. I'd use it only when several teams need independent releases, and I'd isolate each remote behind an error boundary."
Summary
Code splitting divides one build so the first screen downloads only what it needs: dynamic import() makes the chunks, React.lazy and Suspense render them, and preloading, transitions and error boundaries keep the experience smooth. Module federation composes separately built and deployed apps at runtime with shared dependencies. Choose it for team independence, not bundle size, and plan for its runtime failure modes.
Use this as one explanation rep, then continue with the React interview questions cluster or a guided prep path.
Built in public
The code behind FrontendAtlas lives on GitHub. A star helps other frontend engineers find it.