Back to Performance
Performance
medium
mid

How do you optimize bundle splitting for faster page loads?

Split per route (frameworks do this automatically), per heavy widget (modals, editors, charts), per locale, and per feature flag. Tune vendor chunking: don't dump all node_modules into one chunk (single dep change invalidates everything), group by stability and usage. Prefetch likely next routes on hover/viewport. Measure with bundle-analyzer; budget chunks at 30-200KB. Avoid waterfalls (split chunk that requires another split chunk synchronously).

8 min read·~5 min to think through

Bundle splitting is about deferring code until needed without creating new problems (waterfalls, too many tiny requests, cache invalidation). The strategy depends on the framework, but the principles are universal.

Where to split

1. Per route. Every page is its own chunk. Frameworks handle this:

  • Next.js: automatic for App Router and Pages Router.
  • React Router: createBrowserRouter with lazy per route.
  • Remix: per-route automatically.
  • Vite + react-router-dom: manual via React.lazy.

2. Per heavy widget. Modals, drawers, rich-text editors, chart libraries, code editors, file uploaders — anything loaded on interaction.

tsx
const Editor = lazy(() => import('./Editor'));
{editing && <Suspense fallback={<Spinner />}><Editor /></Suspense>}

3. Per locale. Don't ship 30 languages of translations to every user.

ts
const messages = await import(`./locales/${locale}.json`);

4. Per feature flag / experiment. Code behind a flag the user can't see shouldn't ship to them.

5. Per device class. Mobile-only or desktop-only features split out.

Vendor chunking

Default of "put all node_modules in one big vendor chunk" is bad: any dep update invalidates the whole vendor cache for users. Better:

  • Group by stability: separate React core (rarely changes) from utility libs (changes more often).
  • Group by usage: deps used only on certain routes belong in those route chunks.
  • Split large deps individually: chart.js, mapbox-gl, pdfjs get their own chunks.

Webpack splitChunks.cacheGroups config or Next's automatic chunking handles this.

Avoiding waterfalls

A chunk that depends on another chunk creates a sequence: load A → discover need for B → load B. Total time = A latency + B latency.

Fix:

  • Co-locate dependencies so they ship together.
  • Prefetch B when A loads, so by the time A wants B it's already cached.
  • Don't dynamic-import inside lazy chunks unnecessarily.

Preloading next-likely chunks

tsx
<Link href="/admin" prefetch>Admin</Link>   // Next.js prefetches on viewport

Or on hover:

tsx
<a
  href="/admin"
  onMouseEnter={() => import('./admin/page')}
>
  Admin
</a>

Idle-time prefetch via requestIdleCallback for less-certain navigations.

Chunk size sweet spot

  • Too small (< 30KB): HTTP overhead dominates, too many requests.
  • Too large (> 500KB): defeats the splitting purpose.
  • Sweet spot: 50–200KB compressed per chunk.

For HTTP/2/3 with multiplexing, slightly more smaller chunks is fine; for HTTP/1, fewer larger chunks.

Caching with hashed filenames

ts
main.abc123.js
admin.def456.js

Each chunk has a content hash. Long max-age + immutable on the URL. New content → new hash → new URL → users get the new code without invalidating the old.

Measuring

  • Bundle analyzer (webpack-bundle-analyzer, vite-bundle-visualizer, next-bundle-analyzer): visualize the chunk graph.
  • Coverage tab (Chrome DevTools): see what JS was actually executed on a page.
  • Network panel: identify waterfalls (sequential blocking requests).
  • Lighthouse: "Reduce unused JavaScript," "Avoid chaining critical requests."

Set a CI budget

js
// size-limit config
[
  { name: 'main', path: 'dist/main.*.js', limit: '150 KB' },
  { name: 'vendor', path: 'dist/vendor.*.js', limit: '200 KB' },
]

PR fails if a chunk regresses. Without enforcement, splits get undone by accidental imports over time.

Common pitfalls

  • Splitting tiny components: HTTP overhead exceeds the payload — keep them bundled.
  • Splitting on the critical path: visible loading spinner replaces invisible parsing — bad UX.
  • Mega vendor chunk: any dep update invalidates the whole thing.
  • Waterfalls: lazy-route → lazy-component → lazy-data. Each step adds latency.
  • Forgetting Suspense: React.lazy without Suspense throws.
  • Not prefetching: every nav waits for a network round-trip even when the next route is predictable.

Decision flow

  1. Per route: should be automatic. Verify.
  2. Heavy widgets: split if > 30KB and not always rendered.
  3. Vendor: split by stability/usage, not just node_modules.
  4. Prefetch: hover + viewport for predictable navs.
  5. Budget: set in CI before things regress.

Modern context

  • React Server Components (RSC) skip the JS entirely for server-only code — even better than splitting.
  • 'use client' boundaries in Next 13+ define exactly what JS ships.
  • Module Federation for microfrontends: chunks are shared across deploys.

Bundle splitting is less critical than it was when RSC didn't exist, but for the JS that does ship to the client, the rules above still hold.

Follow-up questions

  • How do React Server Components change bundle splitting?
  • What's the right vendor-chunking strategy?
  • How do you avoid lazy-load waterfalls?
  • When does code splitting hurt?

Common mistakes

  • One mega vendor chunk — single dep change invalidates everything.
  • Splitting tiny components — HTTP overhead > payload.
  • Splitting on the critical path — loading state replaces invisible parse.
  • Forgetting prefetch — every nav round-trip.
  • Waterfalls from chained dynamic imports.
  • No CI budget — splits regress quietly over time.

Performance considerations

  • Smart splitting drops initial JS 40-70% on typical apps without changing UX. Prefetching makes the deferred chunks invisible to users on common navigations. Cumulative effect: faster TTI, better INP, smaller deploy cache invalidation on updates.

Edge cases

  • Magic comments (webpackChunkName) for stable chunk names — better long-term caching.
  • Locale chunk: dynamic-import path patterns can match more than intended.
  • Module Federation: shared deps across federated apps to avoid double-loading.
  • SSR: lazy components render fallback on server; hydration must match.
  • RSCs: server-only code doesn't ship at all — pure win vs splitting.

Real-world examples

  • Next.js auto-splits by route and prefetches Link components in viewport.
  • Slack pre-caches likely-next channels' code.
  • Stripe Dashboard splits per major route with hover prefetch on nav.

Senior engineer discussion

Seniors articulate splitting as a budget allocation problem: define the initial-JS budget, fit critical code in it, defer the rest with prefetch hints. They reach for RSC where possible and use bundle analyzer regularly. They also enforce budgets in CI rather than trusting team discipline alone.

Related questions