Component-Level Code Splitting Beyond Routes

A route chunk that clears every routing rule can still be 400 KB. The router did its job — /dashboard no longer ships /settings — but the dashboard route itself statically imports a charting library, a rich-text editor used only inside a modal, a date picker rendered in a collapsed filter panel, and a map component below the fold. All four are in the route chunk, all four are parsed and evaluated before the first meaningful paint, and none of them are visible when the user arrives.

This is the failure mode that route-level splitting cannot see. Route-level code splitting in single-page applications partitions the module graph by navigation target; it has no opinion about what happens within a navigation target. Component-level splitting partitions the same graph by when the user actually needs the code — on interaction, on scroll, or on idle — and it is usually where the second half of the payload reduction comes from once routes are already split.

The cost of skipping it is concrete. On the canonical baseline established in route-based code splitting and dynamic import strategies, an initial JavaScript payload of 150 KB gzipped is the budget. A single unsplit editor component (typically 90–140 KB gzipped with its dependencies) blows that budget on its own, and because it is evaluated during hydration it also pushes Interaction to Next Paint past the 200 ms threshold on mid-tier mobile hardware — even though the user may never open the editor.

Where component splitting sits in the loading architecture

Component splitting is the third partition applied to a module graph, after vendor separation and route separation. Each layer removes a different category of code from the critical path, and each has a different trigger.

Vendor code is separated by change frequency — that is the job of vendor chunk isolation and third-party management, which keeps rarely-changing dependencies in long-lived cacheable chunks. Route code is separated by navigation target. Component code is separated by interaction probability: the likelihood that a given session touches the feature at all.

Three Partitions of the Module Graph A route chunk is subdivided again: vendor code separates by change frequency, route code by navigation target, and component code by interaction probability, leaving a small critical chunk plus deferred component chunks. Three partitions, three triggers 1. Vendor split by change frequency loads: always, cached 2. Route split by navigation target loads: on navigation 3. Component split by interaction odds loads: on trigger vendor.[hash].js cached across deploys dashboard.[hash].js critical view only editor / chart / map never fetched if unused Each partition removes a different category of bytes from the critical path

The practical consequence of that hierarchy is an ordering rule: never split a component before the route it lives in is split, and never split a route before vendor code is isolated. Doing it in the wrong order produces chunks whose contents shuffle every time you change a later layer, which is the same churn problem described in deterministic chunk hashing for long-term caching.

Choosing what to split: the interaction-probability test

Not every heavy component is a good candidate. Three properties have to hold at once:

  • It is not on the critical render path. If the component is visible in the initial viewport for most sessions, deferring it converts a parse cost into a network round-trip and usually makes the experience worse, not better.
  • Its dependency subtree is large. The unit of measurement is not the component file but everything the component uniquely pulls in — the charting library, the locale tables, the syntax-highlighting grammars. A 3 KB component that owns a 90 KB dependency is a 93 KB win.
  • Interaction probability is well under 100%. A modal opened by 8% of sessions is an excellent candidate. A header rendered on every page is not, regardless of its size.

Rank candidates by expected bytes saved — unique subtree size multiplied by the share of sessions that never trigger it. That ranking is derived directly from the treemap output described in reading webpack-bundle-analyzer treemaps, cross-referenced with whatever product analytics you have on feature usage.

Should This Component Be Split? Decision tree: if a component renders on first paint, keep it inline; if its unique subtree is under 15 KB gzipped, keep it inline; otherwise split it and pick a trigger based on how often the feature is used. Candidate component On first paint? for most sessions Keep it inline a request would cost more than the bytes yes Subtree > 15 KB? gzipped, unique no Split it — pick a trigger interaction · viewport · idle yes Leave inline, revisit if it grows no Rank survivors by unique subtree size × share of sessions that never open the feature

Bundler configuration: Webpack 5 and Vite 5+

Both bundlers create a chunk boundary at every import() call automatically. Configuration is mostly about naming those chunks so they are legible in analyzer output and stable across builds, and about setting size floors so the bundler does not create dozens of tiny component chunks that cost more in requests than they save in bytes.

// webpack.config.js — Webpack 5
module.exports = {
  optimization: {
    splitChunks: {
      chunks: 'all',
      // Component chunks below this size are merged back into their parent:
      // a 4 KB chunk is not worth a round-trip on a high-latency connection.
      minSize: 20000,
      maxAsyncRequests: 8,       // cap parallel component chunk requests per navigation
      cacheGroups: {
        // Keep heavy, rarely-used widget dependencies in their own group so a
        // chart and an editor never end up merged into one shared chunk.
        widgets: {
          test: /[\\/]node_modules[\\/](chart\.js|@tiptap|leaflet)[\\/]/,
          name: 'widgets',
          chunks: 'async',       // only when reached via import()
          priority: 20,
          reuseExistingChunk: true,
        },
      },
    },
  },
};
// vite.config.js — Vite 5+
import { defineConfig } from 'vite';

export default defineConfig({
  build: {
    rollupOptions: {
      output: {
        // Rollup names async chunks from the entry module by default; an explicit
        // pattern keeps component chunk filenames readable in the network panel.
        chunkFileNames: 'assets/[name]-[hash].js',
        manualChunks(id) {
          // Group the three heavy widget libraries into one async chunk rather
          // than letting each component drag its own copy of shared internals.
          if (/node_modules\/(chart\.js|@tiptap|leaflet)\//.test(id)) return 'widgets';
        },
      },
    },
    // Vite warns above 500 KB by default; tighten it so a component chunk
    // that quietly absorbs a large dependency is surfaced at build time.
    chunkSizeWarningLimit: 250,
  },
});

The two configurations differ in an important way. Webpack’s splitChunks operates on the finished graph and can merge your component chunk back into a parent if it falls below minSize; Rollup’s manualChunks is an assignment function that runs per module and never merges. That difference matters when a component chunk turns out smaller than expected — under Webpack it silently disappears, under Vite it ships as a tiny file. The trade-offs between the two chunking models are covered in depth in Webpack 5 vs Vite 5 splitChunks — when to choose which.

Framework integration: React and Vue 3

In React, React.lazy plus Suspense is the standard boundary. The critical detail is where the lazy() call lives: it must be at module scope, not inside a render function, or a new component type is created on every render and the chunk is re-evaluated and the subtree remounted.

// ChartPanel.jsx — React 18+, Webpack 5 or Vite 5+
import { lazy, Suspense, useState } from 'react';

// Module scope: the lazy component identity is stable across renders.
const RevenueChart = lazy(() =>
  import(/* webpackChunkName: "revenue-chart" */ './RevenueChart')
);

export function ChartPanel() {
  const [visible, setVisible] = useState(false);

  return (
    <section>
      <button onClick={() => setVisible(true)}>Show revenue chart</button>
      {visible && (
        // The fallback reserves the exact box the chart will occupy, so the
        // deferred chunk cannot shift the surrounding layout when it lands.
        <Suspense fallback={<div style={{ height: 320 }} aria-busy="true" />}>
          <RevenueChart />
        </Suspense>
      )}
    </section>
  );
}

Vue 3 expresses the same boundary with defineAsyncComponent, which additionally accepts timing controls that React leaves to userland:

// ChartPanel.vue script block — Vue 3.4+, Vite 5+
import { defineAsyncComponent } from 'vue';

const RevenueChart = defineAsyncComponent({
  loader: () => import('./RevenueChart.vue'),
  // Don't flash a spinner for a chunk that resolves from cache in 40 ms.
  delay: 200,
  // Surface a real error state instead of an indefinite pending component.
  timeout: 10000,
  loadingComponent: ChartSkeleton,
  errorComponent: ChartLoadFailed,
});

The delay option deserves emphasis: rendering a loading state immediately produces a visible flicker whenever the chunk is already cached, which is the common case on repeat visits. React has no built-in equivalent, so teams typically achieve the same effect by rendering a fallback that is visually identical to the component’s empty state rather than a spinner.

Whichever framework you use, the trigger — not the syntax — is the design decision. Interaction triggers (click, focus) are the safest default. Viewport triggers via IntersectionObserver suit below-the-fold content. Idle triggers via requestIdleCallback are appropriate only for components with a high interaction probability, and are effectively a form of prefetching; the request-scheduling trade-offs are covered in prefetch and preload strategies for critical routes.

Quantified impact

The numbers below are consistent with the canonical baseline in the section page and assume a dashboard-style application with four heavy widgets on its busiest route.

  • Route chunk reduction: 220 KB → 84 KB gzipped (−62%). Moving the editor, chart, map, and date picker behind interaction triggers removes the four largest contributors from the route chunk while leaving the critical view intact.
  • Interaction to Next Paint: 310 ms → 140 ms. Hydration no longer has to evaluate four unused component trees, which brings INP inside the 200 ms threshold on mid-tier mobile hardware.
  • Bytes never downloaded: 55–70% of widget code per session. With the editor opened in roughly 8% of sessions and the map in roughly 30%, the majority of widget bytes are never requested at all rather than merely deferred.
  • First-interaction latency cost: 90–260 ms. This is the price paid on the sessions that do open a widget — a single chunk request on a warm connection. Prefetching on hover reduces it to near zero for pointer users.
  • Layout shift: unchanged (CLS ≤ 0.02) provided every fallback reserves the loaded component’s box. Without reserved boxes the same change typically pushes CLS past 0.1.

Common pitfalls

The re-created lazy component. Calling lazy() or defineAsyncComponent() inside a render function or a computed property creates a new component type on every evaluation. The symptom is a component that remounts and refetches its data on every parent render, with the chunk request appearing once but the component’s effects running repeatedly. The fix is to hoist the call to module scope, or memoize it for the lifetime of the parent.

The static import that undoes the split. A dynamic import only creates a boundary if no remaining static import reaches the module. Barrel files are the usual culprit: export * from './RevenueChart' in a shared index.js re-attaches the component to whatever imports the barrel, as described in refactoring barrel files to reduce bundle bloat. The diagnostic signal is a lazy chunk that exists in the build output but is never requested at runtime, because its contents are already present in the parent.

Waterfall by nesting. A lazy component that itself lazily imports another component produces two sequential round-trips: the parent chunk must be fetched and evaluated before the child request can even be discovered. On a 200 ms round-trip that is 400 ms of latency before anything renders. Flatten the boundary or start both requests together, using the technique described in preventing waterfall requests with dynamic import maps.

Over-splitting into request storms. Twenty component chunks of 6 KB each are strictly worse than four chunks of 30 KB: HTTP/2 multiplexing removes the connection cost but not the per-request overhead, and each chunk carries its own runtime registration. Webpack’s minSize and maxAsyncRequests exist precisely to bound this; Vite requires you to enforce it in manualChunks.

Unreserved layout. A Suspense fallback of null collapses the container to zero height, then expands it when the chunk lands. This is invisible in local development against a warm cache and highly visible on a real connection.

Nested Lazy Boundaries Serialize Requests Two request timelines: nested lazy components fetch the parent chunk, evaluate it, then fetch the child chunk, taking roughly twice as long as a flattened boundary that requests both chunks at once. Nested boundary — 2 sequential round-trips fetch panel chunk evaluate fetch chart chunk render ≈ 520 ms Flattened boundary — requests issued together fetch panel chunk fetch chart chunk evaluate render ≈ 260 ms 0 ms 600 ms

Verification workflow

  1. Confirm the chunk exists. After rebuilding, list the output directory and check for a chunk named after the component. If Webpack merged it away, the chunk simply will not be there — compare against minSize.

  2. Confirm the chunk is absent from the initial load. Open the route with an empty cache and a fresh profile, and check the Network panel: the component chunk must not appear until the trigger fires. If it appears immediately, something still imports the module statically.

  3. Confirm the parent shrank. Compare the route chunk’s gzipped size before and after. A split that adds a new file without shrinking the parent means the module is duplicated, not moved — the duplicate-detection workflow in finding duplicate dependencies in a bundle will identify which copy remains.

  4. Trigger the component and time it. With network throttling set to a realistic mobile profile, fire the trigger and record the interval from the event to the component’s first paint. Anything above 500 ms warrants a prefetch on hover or focus.

  5. Check layout stability. With the Performance panel recording, trigger the component and confirm no layout-shift entries are attributed to its container. A shift here means the fallback box does not match the loaded component’s box.

  6. Lock it in CI. Add the route chunk’s post-split size to your size budget so a future static import cannot silently pull the widget back in. The mechanics are in enforcing performance budgets in CI.

FAQ

How heavy does a component need to be before splitting it out is worth it?

As a working threshold, a component worth splitting is one that costs more than about 15 KB gzipped including its unique dependencies, and is not rendered on first paint for the majority of sessions. Below that size the extra HTTP request, the runtime bookkeeping of another chunk, and the risk of a visible loading state usually cost more than the bytes saved. The dependency subtree matters more than the component’s own source: a 3 KB wrapper around a 90 KB charting library is a 93 KB split, not a 3 KB one.

Does component-level splitting replace route-level splitting?

No — it layers underneath it. Route splitting decides which code a navigation needs at all; component splitting decides which of that code is needed immediately versus on interaction. A route chunk that is still 400 KB after route splitting has a component problem, not a routing problem, and no amount of further route subdivision will fix it. The two techniques target different axes of the same module graph and are almost always used together.

Why does my split component still end up in the main chunk?

Almost always because something else still imports it statically. A single remaining static import anywhere in the graph — a barrel file re-export, a test helper pulled into the app build, a type-only import that was not erased — pulls the module back into the parent chunk, and the dynamic import then resolves against the already-bundled copy. Search the build for every importer of the module and confirm that all of them are dynamic, or that the static importers are themselves inside the same lazy boundary.

How do I stop a lazily loaded component from causing layout shift?

Reserve the space before the chunk arrives. The fallback rendered while the chunk is in flight must occupy the same box the real component will occupy — same height, same width, same margins — either by hard-coding the dimensions or by rendering a skeleton built from the component’s own layout. A fallback of null or a small spinner in a container with automatic height is the single most common cause of Cumulative Layout Shift regressions introduced by component splitting.