Dynamic import() Code Splitting Patterns for React

This guide shows how import() expressions become deterministic chunks in a React app, and how to wire React.lazy and Suspense so lazy routes load without waterfalls or require is not defined crashes. It sits under Code Splitting Strategies for Large Applications, which covers the broader vendor- and route-boundary design that these component-level patterns plug into.

The native import() expression, standardized in ECMAScript 2020, is the primary mechanism for on-demand module loading. Paired with React’s lazy and Suspense, it dictates how an application partitions execution contexts, manages network waterfalls, and protects Time to Interactive. We examine how Vite, Rollup, and esbuild translate import() calls into chunk graphs, resolve CommonJS/ESM interop failures, and enforce reproducible splitting.

The problem this solves is concrete: a single bundled entry that ships every route, modal, and chart library on first paint forces the browser to parse and evaluate JavaScript the user may never reach. On a large React application that easily crosses a megabyte of compressed script, and the cost is paid on the main thread before the app is interactive. Splitting at import() boundaries defers that work — the router loads a route’s code only when navigation demands it. But the mechanism is unforgiving: get the interop wrong and a lazy chunk throws require is not defined at runtime, in production, only for the users who reach that route; get the boundary placement wrong and you replace one large download with a chain of serialized round-trips that is slower than shipping everything at once.

These patterns sit at the point in the build pipeline where module resolution has finished and chunk assignment begins. The bundler has already parsed every module into an AST and resolved every static import edge; the import() call is the marker that tells it where to cut the graph. Everything downstream — hashing, vendor extraction, preload hint generation — keys off those cut points, so a misplaced or non-analyzable import() propagates into unstable filenames and cache misses. Understanding the transformation is therefore a prerequisite for reasoning about why a chunk exists, why it duplicated a dependency, or why its hash churns on every build.

React.lazy import resolution through a Suspense boundary A React.lazy call triggers a dynamic import that fetches a chunk; Suspense shows a fallback until the promise resolves to a default export. React.lazy(() => import('./Page')) Suspense fallback shown while pending fetch chunk Page-[hash].js resolve { default } mount component named export? map .then(m => ({ default: m.Page }))
Figure: a React.lazy dynamic import resolves to a default export through a Suspense boundary; named exports must be mapped explicitly.

Prerequisites & Reproducible Setup

# Vite 5.x / React 18, Node 20+
npm create vite@latest split-demo -- --template react-ts
cd split-demo && npm i
npm i react-router-dom@6
npm i -D rollup-plugin-visualizer@5

This assumes React 18+ (for Suspense on the client and streaming SSR), Vite 5.x or 6.x, and Rollup 4.x for the production build.

Toolchain for React dynamic-import splitting The setup is React 18 for client Suspense and streaming SSR, Vite 5 or 6 with Rollup 4 for the build, react-router 6 for route boundaries, and rollup-plugin-visualizer for verification. React 18Suspense + streaming Vite 5/6 · Rollup 4production build react-router 6route boundaries visualizertreemap proof
Figure: React 18 is required for the Suspense and streaming behaviours these patterns rely on.

AST Transformation & Chunk Graph Generation

Modern bundlers do not defer chunk boundary decisions to runtime. They perform static analysis on the Abstract Syntax Tree during the build to construct a dependency graph that dictates module partitioning. When the parser encounters import('./Module'), it marks the target and its transitive dependencies as a split point.

As detailed in Core Concepts of Modern Bundling, the shift from runtime require() resolution to compile-time ESM resolution changes how chunk graphs are constructed. Rollup and Vite traverse the AST for static string literals inside import() calls. If the path is fully resolvable at build time, the bundler generates a deterministic chunk id, computes a hash, and emits a separate file. If the path contains dynamic interpolation (e.g. import(`./locales/${lang}.js`)), the bundler falls back to glob-based resolution or warns, depending on strictness.

Static import() becomes a chunk; dynamic interpolation warns A statically resolvable import path becomes a deterministic hashed chunk at build time; a path with runtime interpolation falls back to glob resolution or a warning because the bundler cannot pick one target. import('./Page') import(`./${lang}.js`) deterministic hashed chunk glob fallback / warning A static string literal is resolvable at build time; interpolation is not.
Figure: a literal path yields one chunk; interpolation forces glob resolution or a warning.

How chunk hashing works under the hood

The hash in Page-[hash].js is not a random build id; it is a content hash derived from the emitted chunk’s final source, after tree-shaking and minification. Rollup computes it in a settling pass: because a chunk’s content can include import statements referencing the hashes of chunks it depends on, and those hashes depend on their own content, the graph is resolved iteratively until every hash is stable. This is why a change to a leaf utility ripples upward — the utility’s chunk hash changes, which changes the import string inside every chunk that references it, which changes those chunks’ hashes in turn. The property you want from this is precise cache invalidation: an unchanged route keeps its filename across builds and stays in the browser and CDN cache, while only the genuinely changed chunks are re-fetched. The failure mode is hash churn, where an unrelated edit rotates half the filenames because a shared module sits too deep in the import graph. Keeping shared utilities in a dedicated stable chunk (below) is the usual remedy.

Chunk graph topology is governed by three constraints:

  1. Entry point isolation. Each route or application entry maintains an independent dependency tree. Shared modules between entries are hoisted into vendor or shared chunks.
  2. Dynamic import boundaries. Every distinct import() path creates a new chunk node. The bundler computes the dependency intersection to prevent duplication.
  3. ESM spec compliance. Native dynamic imports return promises resolving to module namespaces. Bundlers must preserve the __esModule flag and default-export semantics so React components resolve correctly.

Diagnosis Workflow: Uncaught ReferenceError: require is not defined

A frequent failure in ESM-first builds is:

Uncaught ReferenceError: require is not defined at __require (chunk-vendor.js:12)

This occurs when a bundler emits an ESM-only chunk but inlines a CommonJS dependency that relies on require. With output.format: 'es', the bundler strips Node-style polyfills. If a dynamically imported package (legacy lodash, moment, an unmaintained UI library) exports via module.exports or calls require() internally, the chunk executes in a browser where require is undefined.

The reason it surfaces in lazy routes specifically, and not in your eagerly imported code, is timing. Vite’s dev server pre-bundles dependencies with esbuild the first time it encounters them, converting CJS to ESM on the fly; anything imported statically at startup is caught in that pass. A package that is only ever reached through a lazy import() may never enter the pre-bundle scan unless you name it explicitly, so it slips through untouched. In the production build the same gap appears when @rollup/plugin-commonjs is not configured to include that package under node_modules, leaving its module.exports and internal require() calls verbatim in an es-format chunk. The error is deferred to the exact moment the user navigates to the route, which is why it evades a smoke test of the home page.

Work the diagnosis in order:

Diagnosing require-is-not-defined in a lazy chunk Confirm the trigger is an ES-format output importing a CJS-only package, audit the package.json for a module type or exports field, then reproduce with vite build --debug to see the module reach the ESM output untransformed. 1 · confirm triggeres format + CJS dep 2 · audit manifesttype/exports field? 3 · --debug grepuntransformed? A CJS-only package in an ES chunk executes require() where it is undefined
Figure: confirm the format/dep pairing, prove the package is CJS-only, then catch it untransformed in the log.
  1. Confirm the trigger. The symptom is a ReferenceError thrown from inside a chunk filename you recognize as a vendor or route split, never from the entry. The root cause is the pairing of output.format: 'es' with a CJS-only dependency that optimizeDeps did not pre-bundle during dev, or that Rollup skipped during the build. Confirm it by checking that the stack frame points into a hashed chunk rather than your own source and that removing the lazy boundary (importing the component statically) makes the error move to load time — proof the package, not your wiring, is the source.
  2. Audit the dependency manifest. Open the offending package’s package.json. A "type": "module" field, or a modern "exports" map with an import condition, means the package already ships ESM and the fault is elsewhere. If the only entry is "main" pointing at a script that uses module.exports or top-level require(), the package is CJS-only and must be transformed before it can live in an es chunk. Confirm by opening that main file and looking for module.exports = or require(.
  3. Reproduce with --debug. Run npx vite build --debug and grep the log for the offending module id. If it appears in the resolved graph but not in the list of modules handled by the commonjs transform, you have confirmed it reached the ESM output untransformed. This is the deterministic reproduction you want before touching config, so you can prove the fix rather than guess at it.

The Solution Config

A single annotated config covers both the interop fix and deterministic chunk naming.

Two-part fix: interop plus deterministic naming The config pre-bundles the CJS package to ESM and applies @rollup/plugin-commonjs so require never reaches the browser, and separately assigns deterministic chunk and vendor names so the graph is reproducible. interop fix optimizeDeps + plugin-commonjs CJS → ESM, no browser require() deterministic naming manualChunks vendor + route-[name] reproducible chunk ids
Figure: the config does two independent jobs — interop so nothing crashes, naming so the graph is stable.
// vite.config.ts — Vite 5.x / Rollup 4.x
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import commonjs from '@rollup/plugin-commonjs';

export default defineConfig({
  plugins: [
    react(),
    // Transform mixed/CJS modules that slip past Vite's pre-bundling.
    commonjs({
      transformMixedEsModules: true, // handle files mixing CJS + ESM
      requireReturnsDefault: 'auto',  // require('pkg') -> correct default
      include: /node_modules\/(legacy-package|moment)/,
    }),
  ],
  // Pre-bundle CJS deps to ESM during dev so require() never reaches the browser.
  optimizeDeps: { include: ['legacy-package', 'moment'] },
  build: {
    rollupOptions: {
      output: {
        format: 'es',
        chunkFileNames: 'chunks/[name]-[hash].js',
        entryFileNames: 'assets/[name]-[hash].js',
        manualChunks(id) {
          if (id.includes('node_modules')) {
            if (id.includes('lodash') || id.includes('moment')) return 'vendor-legacy';
            if (id.includes('react-router')) return 'vendor-routing';
            return 'vendor-core';
          }
          // Route-level splitting keyed on the routes directory.
          const m = id.match(/\/src\/routes\/([^/]+)/);
          return m ? `route-${m[1]}` : undefined;
        },
      },
    },
  },
});

transformMixedEsModules: true forces transformation even when ESM syntax sits alongside CJS. requireReturnsDefault: 'auto' resolves require('pkg') to the correct default without breaking namespace compatibility. For the deeper interop rules, see Understanding ESM vs CommonJS in Modern Bundlers.

The manualChunks function runs once per module id during the build, and its return value is the name of the chunk that module is assigned to. Returning undefined (or null) leaves the module under Rollup’s default grouping, which co-locates it with the dynamic import that first pulled it in. The ordering matters: the node_modules branch is checked first so vendor code is pinned to stable, rarely-changing chunk names, and only application code reaches the route regex. Pinning vendors this way is what keeps their hashes stable across deploys — vendor code changes far less often than route code, so it should not share a chunk with anything that does. The most common mistake here is a manualChunks that returns a fresh name per module; that defeats grouping entirely and produces hundreds of one-module chunks, each with its own request.

Because a network fetch for a lazy chunk can fail — a stale hash after a deploy, a flaky connection, an ad blocker — a bare Suspense boundary is not enough. Suspense handles the pending state; it does nothing for rejection. Pair it with an error boundary that can retry:

// React 18.x — retry a failed lazy chunk fetch
import React, { Suspense } from 'react';

class ChunkErrorBoundary extends React.Component<
  { children: React.ReactNode; fallback: React.ReactNode },
  { failed: boolean }
> {
  state = { failed: false };
  static getDerivedStateFromError() {
    return { failed: true };
  }
  render() {
    if (this.state.failed) {
      // A hard reload re-fetches the current chunk manifest after a deploy.
      return (
        <button onClick={() => window.location.reload()}>Reload page</button>
      );
    }
    return this.props.children;
  }
}

export const SafeRoute = ({ children }: { children: React.ReactNode }) => (
  <ChunkErrorBoundary fallback={null}>
    <Suspense fallback={<div className="skeleton">Loading…</div>}>
      {children}
    </Suspense>
  </ChunkErrorBoundary>
);

The deploy case is the one worth internalizing: when you ship a new build, chunk hashes rotate, and a user with the old index.html still cached will request chunk filenames that no longer exist on the CDN, producing a ChunkLoadError. The reload above re-fetches the current HTML and its fresh chunk manifest, which is the only reliable recovery short of retaining old chunks on the origin for a grace period.

React.lazy with default and named exports

Dynamic imports must resolve to a module with a default export. Named exports require an explicit map:

import React, { Suspense } from 'react';

// Named export needs an explicit mapping to { default }.
const Dashboard = React.lazy(() =>
  import('./routes/Dashboard').then((m) => ({ default: m.Dashboard }))
);

// Default export needs no mapping.
const Settings = React.lazy(() => import('./routes/Settings'));

export const AppRoutes = () => (
  <Suspense fallback={<div className="skeleton">Loading…</div>}>
    <Dashboard />
    <Settings />
  </Suspense>
);

Rollup (graph-aware shared chunk)

// rollup.config.js — Rollup 4.x
export default {
  input: 'src/main.tsx',
  output: {
    dir: 'dist',
    format: 'es',
    manualChunks(id, { getModuleInfo }) {
      const info = getModuleInfo(id);
      // Prevent shared utilities from duplicating across lazy chunks.
      if (id.includes('/src/utils/') && info && info.importers.length > 2) {
        return 'shared-utils';
      }
      return null;
    },
  },
};

esbuild

esbuild has no function-based manualChunks, but supports deterministic naming for ESM splitting:

# esbuild 0.25.x, Node 20+
esbuild src/main.tsx --bundle --splitting --outdir=dist \
  --chunk-names='chunks/[name]-[hash]' --format=esm --minify

Verification

Add the visualizer and rebuild to confirm chunk boundaries match intent:

Three checks in the treemap and waterfall Confirm each lazy route is its own chunk, that no dependency duplicates across lazy chunks, and that under throttled preview the route chunks fetch in parallel rather than serializing. each route = one chunktreemap no cross-chunk dupestreemap parallel fetchwaterfall, throttled
Figure: the treemap confirms boundaries; the waterfall confirms they load in parallel, not serialized.
// vite.config.ts — verification only
import { visualizer } from 'rollup-plugin-visualizer';
// add to plugins:
visualizer({ template: 'treemap', gzipSize: true, filename: 'dist/stats.html' });

In dist/stats.html, confirm each lazy route is its own chunk, no duplicate dependencies span lazy chunks, and no wildcard import pulled an unexpected module in. Then capture the network waterfall under npx vite preview with throttling: route chunks should fetch in parallel, not serialize.

Gotchas & Edge Cases

Four React lazy-loading edge cases Nested dynamic imports serialize two round-trips; SSR hydration mismatches need identical Suspense boundaries; StrictMode double-mounts a lazy chunk in dev; and over-splitting into tiny chunks rarely helps. nested import waterfalls a lazy child import serializes two hops — flatten and modulepreload the next route. SSR hydration mismatch use identical Suspense boundaries and renderToPipeableStream on both sides. StrictMode double-mount dev mounts twice — validate fetch counts in a production build. over-splitting keep entry < ~170KB, 4–6 requests per transition, stable hashes.
Figure: two loading-order traps and two "measure the production build" traps.

Waterfalls from nested dynamic imports

The symptom is a route that visibly loads in two stages under a throttled network: the route shell appears, then a spinner inside it, then the content. The root cause is a lazy component that itself triggers a dynamic import() during first render — the browser cannot begin the second fetch until the first chunk has downloaded, parsed, and executed far enough to reach the nested import(), so the round-trips serialize. The fix is to flatten the tree so a route’s code arrives in one chunk, and to preload the code you know the user is heading toward with <link rel="modulepreload" href="/chunks/route-dashboard-[hash].js"> in the document head, which starts the fetch in parallel with the current work rather than after it. Confirm the fix in the network waterfall: the two requests should overlap instead of forming a staircase.

Hydration mismatches in SSR/SSG

The symptom is a console warning about server/client markup mismatch, often followed by React discarding the server HTML and re-rendering from scratch, which erases the performance benefit of server rendering. The root cause is that the server emitted markup for a component the client only loads asynchronously, so the two trees disagree during hydration. Wrap lazy components in identical Suspense boundaries on both client and server so the boundary — not the resolved component — is what hydration reconciles, and use renderToPipeableStream (React 18) to flush HTML as boundaries resolve rather than blocking on the slowest async chunk. Confirm by hydrating with the network throttled: the warning should not appear and the server markup should be reused.

StrictMode double-invocation

The symptom is a lazy chunk that appears in the network panel to be fetched twice, or an effect that runs twice, prompting a hunt for a bug that is not there. The root cause is React 18’s development StrictMode, which intentionally double-invokes mount logic to surface impure effects. It does not affect production. Confirm by building and serving the production bundle (vite build && vite preview) and re-checking the fetch count; if it drops to one, there was nothing to fix. Do not add deduplication logic to silence a dev-only signal.

Over-splitting

The symptom is a transition that fires a burst of a dozen tiny requests and feels slower than before splitting. The root cause is that request overhead — connection scheduling, header exchange, and per-chunk parse setup — dominates once chunks fall below roughly 20 KB, so five 10 KB chunks cost more than one 50 KB chunk they replaced. Keep entry chunks under ~170 KB compressed, limit concurrent dynamic requests to 4–6 per transition, and keep chunk hashes stable across builds so CDN caching holds. Confirm by comparing the total transition time in the throttled waterfall before and after coarsening the boundaries — fewer, larger chunks usually win. For vendor-boundary design at the application scale, see Code Splitting Strategies for Large Applications.