Back to Performance
Performance
easy
mid

How do dynamic imports enable code splitting in a frontend app?

Code splitting breaks the JS bundle into chunks loaded on demand. Tools: dynamic import() (browser-native), React.lazy + Suspense, Next.js dynamic(). Common splits: per-route, per-modal/heavy widget, per-rarely-used path (CSV export, charts library). Trade-off: smaller initial bundle but extra requests + a moment of loading UI. Pair with preload hints for predictable navigations.

6 min read·~5 min to think through

Code splitting = ship the JS your app needs now, defer the rest until it's actually used. dynamic import() is the language primitive; React.lazy and Next's dynamic() wrap it for React component loading.

Why

Without code splitting, every line of every feature lands in one bundle. The user pays for the chart library on the dashboard, the CSV exporter, and the admin settings panel before even seeing the login page. First-load metrics (TTI, LCP) suffer.

Tools

1. Dynamic import (vanilla JS)

js
button.onclick = async () => {
  const { default: heavy } = await import('./heavy-tool.js');
  heavy.run();
};

Bundlers (webpack, esbuild, vite, rollup) split heavy-tool.js into its own chunk that loads on demand.

2. React.lazy + Suspense

jsx
const Chart = React.lazy(() => import('./Chart'));

function Dashboard() {
  return (
    <Suspense fallback={<Spinner />}>
      <Chart />
    </Suspense>
  );
}

3. Next.js dynamic()

jsx
import dynamic from 'next/dynamic';
const Map = dynamic(() => import('./Map'), {
  ssr: false,        // skip SSR for browser-only deps (window, leaflet, etc.)
  loading: () => <Spinner />,
});

Where to split (highest ROI first)

  1. Per-route — every page is its own chunk. Frameworks (Next, Remix, React Router lazy routes) do this by default.
  2. Modal / drawer / dialog content — rarely opened, often heavy (rich text editor, chart, video player).
  3. Heavy libraries used in one spot — Chart.js, Monaco, Mapbox, PDF renderer.
  4. Per-locale data — load French translations only for French users.
  5. Admin / feature-flagged code — don't ship to users who can't see it.

Where NOT to split

  • Anything needed for first paint (header, layout, primary CTA).
  • Tiny components (split adds a request; payload smaller than HTTP overhead).
  • Code on the critical user path (the loading spinner is visible latency).

Preloading: best of both

If you know the user is about to need a chunk (hover over a nav link, idle CPU), prefetch it:

html
<link rel="prefetch" href="/_next/static/chunks/admin.js">

Or programmatically:

js
// On hover
link.onmouseenter = () => import('./admin/page');

Next's <Link prefetch> does this automatically on viewport entry.

Measuring impact

  • Bundle analyzer (next-bundle-analyzer, webpack-bundle-analyzer, vite-bundle-visualizer): see the chunk graph.
  • Lighthouse: "Reduce unused JavaScript" flags split candidates.
  • Coverage tab in Chrome DevTools: shows which JS was actually executed on a page.
  • Real metrics: track LCP, TTI, FCP before/after — split that doesn't move the metric isn't worth the complexity.

Common pitfalls

  • Waterfalls: route chunk → fetches another chunk → fetches data. Prefetch or co-load to flatten.
  • Layout shift on load: reserve space for the lazy-loaded component (skeleton sized to match).
  • SSR mismatch: components that depend on window need { ssr: false } in Next, or 'use client' + conditional render.
  • Over-splitting: 50+ tiny chunks make HTTP/2 push less effective than one medium chunk. Aim for 30–200KB per chunk.
  • Vendor mega-chunk: putting all node_modules into one big vendor chunk defeats the purpose. Split by usage.

Follow-up questions

  • How does React.lazy interact with SSR?
  • What's the difference between prefetch and preload?
  • When does code splitting hurt rather than help?
  • How would you split a vendor chunk effectively?

Common mistakes

  • Splitting components on the critical render path — adds visible loading state.
  • Forgetting Suspense boundary around React.lazy — error.
  • Not prefetching predictable navigations — every click waits for a network round-trip.
  • Splitting tiny components (1KB) — HTTP overhead dwarfs the payload.
  • Putting all node_modules into one mega-vendor chunk — single change invalidates the whole cache.
  • Forgetting ssr: false for browser-only libs in Next — SSR crashes referencing window.

Performance considerations

  • Code splitting trades initial bytes for latency on demand. The win is real for first paint and TTI; the cost is a loading state on navigation. Aim for ~150KB compressed initial JS for content sites, ~300KB for SaaS dashboards. Preload + cache prediction reduces the on-demand latency to near-zero for common paths.

Edge cases

  • Webpack magic comments: import(/* webpackChunkName: 'admin' */ './Admin') for naming.
  • Dynamic imports inside loops can cause N chunks; hoist or use a static import map.
  • React Server Components (RSC) shift the conversation — server code never ships, no split needed.
  • Module Federation (microfrontends) is code splitting across deploys, not just builds.
  • Lazy-loaded routes lose URL state if not handled — use the router's lazy route API, not raw React.lazy.

Real-world examples

  • Next.js auto-splits by route; <Link> prefetches in viewport.
  • Slack splits the message editor, file viewers, call UI as separate chunks loaded on demand.
  • Figma loads the editor on /file/* routes; the marketing site doesn't ship the editor bundle at all.

Senior engineer discussion

Seniors think of code splitting as a budget exercise: define an initial JS budget (e.g., 150KB gzipped), then for everything that doesn't fit, decide between deferring (split) and dropping (rip out the dep). They prefetch aggressively on idle/hover to hide split latency, watch for CDN-cache invalidation patterns (one frequently-changing chunk vs many stable ones), and pair splitting with RSC where possible to skip the JS entirely.

Related questions