Frontend interview practice question

How do you code split a React app, and when do you need module federation instead?

HighHardReact

Interview quick answer

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.

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
Practice more React interview questions
Interview answer drill

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.

JSX
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

Split where large code is not needed for the first screen

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.

JSX
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.

JSX
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.

JS
// 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

The same lazy loading on the surface, different problems underneath

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.

Guides
Preparing for interviews?

Built in public

The code behind FrontendAtlas lives on GitHub. A star helps other frontend engineers find it.

Star on GitHub