Skip to content

Route-Level vs Component-Level Code-Splitting in React 19

React.lazy plus route-level splitting cuts real bundle size, but splitting every component adds request overhead that can cost more than it saves.

· · 5 min read

Quick Take

I once split a 40-line component into its own lazy-loaded chunk because a linter suggested it. The extra network round trip cost more than the component's own weight would have on the main bundle.

Bundle-size advice tends to stop at "use React.lazy," without saying when not to. I found the boundary the hard way, splitting a component small enough that its own lazy chunk's request overhead outweighed whatever bytes it removed from the main bundle. The fix was un-splitting it.

TL;DR: Route-level code-splitting (one chunk per page) is close to a free win in almost every React app. Component-level splitting only pays off for genuinely heavy, conditionally-rendered pieces, a rich text editor, a charting library, an admin-only panel, not for small components or anything that renders on every visit. Measure the actual chunk size before splitting; a 2KB component gains nothing from its own request.

Route-Level Splitting: Do This by Default

import { lazy, Suspense } from 'react';
import { Routes, Route } from 'react-router-dom';

const HomePage = lazy(() => import('./pages/HomePage'));
const SettingsPage = lazy(() => import('./pages/SettingsPage'));
const AdminDashboard = lazy(() => import('./pages/AdminDashboard'));

function App() {
  return (
    <Suspense fallback={<PageSkeleton />}>
      <Routes>
        <Route path="/" element={<HomePage />} />
        <Route path="/settings" element={<SettingsPage />} />
        <Route path="/admin" element={<AdminDashboard />} />
      </Routes>
    </Suspense>
  );
}

A user visiting / never downloads AdminDashboard's JavaScript, which might include heavy charting or table libraries only relevant to admin users. This is close to a no-downside win: each route is already a natural, meaningful boundary a user visits deliberately, so the extra request per navigation is a request they were going to make anyway (a page navigation), not one added purely for the sake of splitting.

Component-Level Splitting: Measure First

import { lazy, Suspense, useState } from 'react';

// Good candidate: a rich text editor is genuinely heavy (often 100KB+)
// and only needed if the user opens the "write a review" form
const RichTextEditor = lazy(() => import('./RichTextEditor'));

function ProductPage() {
  const [showReviewForm, setShowReviewForm] = useState(false);

  return (
    <div>
      <button onClick={() => setShowReviewForm(true)}>Write a review</button>
      {showReviewForm && (
        <Suspense fallback={<EditorSkeleton />}>
          <RichTextEditor />
        </Suspense>
      )}
    </div>
  );
}

Two things make this a good split: the component is genuinely large (a rich text editor commonly pulls in a substantial dependency tree), and it's conditionally rendered, most visitors to a product page never click "write a review" at all, so most users never download that chunk.

The Bad Split: Small and Always-Rendered

// Bad: this component is small, and renders on every single page load
const Footer = lazy(() => import('./Footer'));

function Layout({ children }: { children: React.ReactNode }) {
  return (
    <div>
      <Header />
      {children}
      <Suspense fallback={<div style={{ height: 120 }} />}>
        <Footer />
      </Suspense>
    </div>
  );
}

The footer renders on every page, for every user, unconditionally. Lazy-loading it doesn't defer anything meaningful, it just adds a network request and a loading state to something that was always going to load anyway. Measure the actual chunk size with a bundle analyzer before assuming any split helps: if Footer.js compiles to 3KB gzipped, the main bundle barely shrinks and you've added latency for nothing.

Measuring the Actual Trade-off

npx vite-bundle-visualizer
# or, for webpack:
npx webpack-bundle-analyzer dist/stats.json

Run this before and after a split. The question to answer isn't "did I split it," it's "did the main bundle shrink by more than the new chunk's overhead costs in latency for the users who need it." A component that's 40KB uncompressed and used by 5 percent of visitors is a clear win split out. A component that's 3KB and used by 90 percent of visitors is not, splitting it just adds a request for almost everyone with negligible bundle savings for anyone.

Preloading a Lazy Chunk Before It's Needed

For the middle case, a component that's genuinely heavy but likely to be needed soon (not on initial render, but predictably within the next few seconds), preloading hides the network latency without loading it eagerly:

const RichTextEditor = lazy(() => import('./RichTextEditor'));

function ReviewButton() {
  function handleMouseEnter() {
    // Start the fetch on hover, before the user actually clicks
    import('./RichTextEditor');
  }

  return (
    <button onMouseEnter={handleMouseEnter} onClick={() => setShowReviewForm(true)}>
      Write a review
    </button>
  );
}

The import() call on mouseenter kicks off the network request early, so by the time the user actually clicks and the component renders, the chunk is likely already cached. This is a genuine middle ground: the component still doesn't load for users who never hover the button, but it doesn't make the ones who do click wait for a cold request either.

Old Way vs Modern Way

ScenarioSplit?Reason
Each route/pageYesNatural navigation boundary, users don't need other pages' code
Heavy, conditionally-rendered component (editor, chart library)YesReal bytes saved for the majority who never trigger it
Small component, always renderedNoRequest overhead exceeds any bundle savings
Admin-only feature in a consumer-facing appYesMost users never download admin code at all

Conclusion

Route-level splitting is close to a default-yes. Component-level splitting needs the same question asked of it every time: is this component both heavy and conditionally needed, or does splitting it just add a request to something that was always going to render. Measure the chunk size before deciding, a linter suggestion isn't a substitute for that number.

Frequently Asked Questions

What's the difference between route-level and component-level code-splitting?
Route-level splitting creates a separate chunk per page or route, so a user visiting the homepage never downloads the settings page's JavaScript until they navigate there. Component-level splitting goes further, lazy-loading an individual component within a route, a modal, a chart, a rich text editor, that isn't needed on initial render even for that page. Route-level splitting almost always pays off; component-level splitting only pays off for genuinely heavy, conditionally-rendered components.
When does React.lazy make bundle size worse instead of better?
When you split something small enough that the extra network request's overhead (DNS/connection reuse aside, there's still request latency and a separate parse/compile step) costs more than the bytes saved from the main bundle. A 2KB component lazy-loaded on its own chunk is a common example: the main bundle barely shrinks, but the component now needs its own round trip before it can render, adding latency for no measurable size win.
Should I lazy-load a modal that opens on every page load?
No. Lazy loading only helps when a component is conditionally needed, not needed on every visit. A modal that opens automatically on load defeats the purpose, the lazy chunk has to load anyway before the modal can appear, so you've added a loading state and a network round trip with no actual deferral benefit. Reserve lazy loading for things a meaningful fraction of users never trigger: a rarely-opened settings panel, an admin-only feature, a heavy library used by one uncommon action.