12k
All articles

Code Splitting Strategies for Your Frontend App

Compare frontend code splitting strategies: split routes and heavy components, prefetch on interaction, configure vendor chunks, and measure performance with network waterfalls.

OpenReplay Team
OpenReplay Team
Code Splitting Strategies for Your Frontend App

Code splitting breaks a JavaScript bundle into separate chunks that the browser downloads only when the code inside them is needed, so the first page load pays for the first page instead of the whole app.

Lighthouse reports a pile of unused JavaScript, and you already added React.lazy to one component, yet production still ships a single large entry chunk. The fix isn’t more lazy() calls everywhere. You need to know which splits are worth making, what each one costs, and how to tell whether it worked.

This article covers five strategies in the order they usually pay off, how to measure them, and the three ways code splitting makes an app slower.

Key Takeaways

  • Route-based splitting is the first split to make in almost every single-page app, because each route becomes its own chunk and visitors download only the route they land on.
  • webpack 5 and Rolldown (the bundler in Vite 8) already move code shared by lazily loaded chunks into common chunks, so a hand-built vendor.js is mostly useful for cache stability now, not for size.
  • If you start import() on hover or focus instead of on click, the chunk downloads while the user is still deciding whether to click.
  • A smaller entry chunk proves nothing by itself. The proof is a throttled network waterfall where the code for the first screen finishes loading earlier than before.
  • Splitting code the first screen needs doesn’t remove the wait. It moves the wait later.

Why Does One Bundle Slow Down the First Page?

Without splitting, a single-page app ships one entry bundle that contains every route, modal and library the app could ever use. Someone landing on your marketing page downloads, parses and executes the settings screen, the admin dashboard and the charting library, even if they never open any of them. That unused code competes for bandwidth and main-thread time with the code that actually renders the page.

The five code splitting strategies below each have a different trigger, a different cost and a different way to check that they worked:

StrategyWhen it appliesWhat it costsHow to check it worked
Route splittingAny multi-route SPAA fallback on each navigationThe entry chunk no longer holds other routes
Component splittingHeavy UI behind an interaction or below the foldA loading state and an extra requestThe chunk loads only when triggered
Manual vendor chunksStable dependencies you want cached long termConfig upkeep and possible changes to execution orderThe hash stays the same across deploys
Interaction prefetchHigh-probability clicksOccasionally fetching code nobody usesThe request starts before the click
Don’t splitCode the first screen needsNoneNo new request chains

Route-Based Code Splitting Comes First

Route-based splitting is the first split worth making in almost every single-page app. Each route becomes its own chunk, and a visitor downloads only the route they land on. It takes little code, and route changes are natural places for a brief loading state. Understanding React dynamic imports walks through the pattern with React.lazy and Suspense. Meta-frameworks such as Next.js and SvelteKit split by route automatically.

Split Heavy Components Behind an Interaction

Component-level splitting fits UI that is heavy and not visible on first render: a modal, a rich-text editor, a charting widget, a comments section below the fold. The cost is a loading state, plus one network request at the moment the user wants the feature.

Every major framework gives you the same primitive on top of dynamic import():

// React
import { lazy } from 'react';
const Chart = lazy(() => import('./Chart'));
// Next.js
import dynamic from 'next/dynamic';
const Chart = dynamic(() => import('./Chart'));
// Vue
import { defineAsyncComponent } from 'vue';
const Chart = defineAsyncComponent(() => import('./Chart.vue'));

A React lazy component renders inside a Suspense boundary, which shows a fallback while the chunk loads. In Next.js, next/dynamic wraps React.lazy() and Suspense into one call, and Vue offers defineAsyncComponent. Svelte uses import() directly with conditional rendering.

For content below the fold, trigger the load when the element comes near the viewport. Create one Intersection Observer, disconnect it after the first intersection, and then render the lazy component. Lazy loading in JavaScript covers the observer patterns.

What Your Bundler Already Splits for You

webpack 5’s default splitChunks settings, and Rollup (Rolldown in Vite 8), move code shared by several lazily loaded chunks into a common chunk automatically. By default, webpack does this only for async chunks (chunks: 'async') and only when the new chunk is above a minimum size. Its built-in defaultVendors cache group already matches node_modules. The current state of JavaScript bundlers gives an overview of where each bundler stands today.

Because the bundler already extracts shared code, a single hand-built vendor.js mostly duplicates what webpack or Rolldown does. The main reason left to define vendor chunks by hand is cache stability. If dependencies that rarely change keep the same content hash across deploys, returning visitors don’t download them again.

// webpack 5
module.exports = {
  optimization: {
    splitChunks: {
      cacheGroups: {
        framework: {
          test: /[\\/]node_modules[\\/](react|react-dom|scheduler)[\\/]/,
          name: 'framework',
          chunks: 'all',
        },
      },
    },
  },
};

You need chunks: 'all' here. Without it, framework code imported by the entry stays inside the entry chunk, and the entry’s hash changes on every deploy.

In Vite 7 and earlier, the same idea was written as build.rollupOptions.output.manualChunks. Vite 8 builds with Rolldown. It no longer supports the object form of manualChunks and deprecates the function form in favour of codeSplitting groups:

// Vite 8 (Rolldown)
import { defineConfig } from 'vite';

export default defineConfig({
  build: {
    rolldownOptions: {
      output: {
        codeSplitting: {
          groups: [{ name: 'vendor', test: /node_modules[\\/](react|react-dom)[\\/]/ }],
        },
      },
    },
  },
});

Keep groups narrow. Rolldown’s documentation warns that hand-made groups can change the order in which modules run, which matters for modules with side effects.

Start Loading on Hover, Not on Click

Interaction prefetching starts import() when the pointer enters a button or the button receives focus, rather than when it’s clicked, so the chunk downloads while the user is still deciding. The download overlaps the gap between intent and action, so the fallback shows briefly or not at all.

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

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

function ReplyBox() {
  const [open, setOpen] = useState(false);
  return (
    <>
      <button onMouseEnter={loadEditor} onFocus={loadEditor} onClick={() => setOpen(true)}>
        Reply
      </button>
      {open && <Suspense fallback={<p>Loading editor…</p>}><RichTextEditor /></Suspense>}
    </>
  );
}

lazy and the event handlers share one loader function. The module system caches the result of the first import(), so the render after the click reuses the request that is already in flight. The cost is some wasted bandwidth when a user hovers but doesn’t click.

Hints such as webpackPrefetch are webpack magic comments. Turbopack in Next.js reads a few webpack-compatible magic comments, but its docs list only import-control comments such as webpackIgnore, not webpackPrefetch, and other bundlers generally don’t read them. Check your bundler’s docs before you rely on one. Triggering import() from an event handler works with every bundler.

Measure the Waterfall, Not the Bundle Size

A smaller entry chunk proves nothing on its own. The evidence that a split helped is a throttled network waterfall in which the code for the first screen finishes loading earlier than it did before.

  1. Run a bundle visualizer for your bundler to confirm what each chunk contains. For webpack, the Webpack Bundle Analyzer walkthrough shows how to spot heavy modules and code the page doesn’t use.
  2. Open the Chrome DevTools Network panel, filter to JS, and apply a throttled mobile network profile.
  3. Compare before and after. Check when the last chunk needed for the first render finishes, and whether any new sequential request chains appeared.

A chain looks like a staircase: each chunk request starts only after the previous one ends. Nested lazy imports produce this pattern.

When Does Code Splitting Make Things Slower?

Code splitting backfires in three predictable ways.

Too many small chunks. Each chunk is a separate request. Splitting small components adds requests and round trips that cost more than the bytes they save.

Splitting the critical path. Splitting code the first screen needs doesn’t remove the wait. It moves it. The browser must now download and run the entry chunk, reach the dynamic import(), and only then request the chunk that renders the content.

A spinner where content used to be. A fallback that replaces content users previously saw instantly can make the app feel slower even when total load time went down.

Pair every lazy boundary with an error boundary. A chunk request can fail, and a tab opened before a deploy may request hashed files that no longer exist. Session replays of code-split apps show things a bundle report can’t: users sitting on Suspense fallbacks, or hitting a ChunkLoadError after a mid-session deploy.

Choosing Which Splits to Keep

Start with routes, then split heavy components behind interactions. Prefetch on hover or focus, and let the bundler handle shared code unless you specifically want stable cache hashes. For every split, compare the waterfall under throttling before and after. If the first screen doesn’t render sooner, or an interaction doesn’t respond sooner, revert the split.

FAQs

Does React.lazy work with named exports?

Not directly. React.lazy needs its loader's promise to settle on a module that has the component as its default export, so a named export needs remapping: lazy(() => import('./Charts').then((m) => ({ default: m.BarChart }))). The chunk splits the same way. Re-exporting the component as a default from a small wrapper file also works and keeps the lazy call short.

How do I recover when a lazy chunk fails to load after a deploy?

Reload the page so the browser fetches the new entry chunk and the current chunk URLs. React keeps the first promise from a lazy loader and never calls the loader a second time, so re-rendering alone will not retry. When a dynamic import fails, Vite fires a 'vite:preloadError' event on window, and calling event.preventDefault() in the listener suppresses the error. Cap automatic reloads to avoid a reload loop.

What is the difference between webpackPrefetch and webpackPreload?

webpackPrefetch downloads a chunk in browser idle time after the parent chunk finishes loading, for code likely needed later, such as the next screen. webpackPreload fetches the chunk at the same time as its parent, at medium priority, for code the parent will ask for straight away. Both are webpack magic comments that emit link hints. Preload is the rarer choice, because overusing it can harm performance.

Does Vite prevent chunk waterfalls automatically?

Partly. When an async chunk depends on a shared common chunk, Vite rewrites the dynamic import with a preload step so both are fetched in parallel instead of one after the other. It also emits modulepreload links for entry chunks and their direct imports. This covers static dependencies only. A lazy component that calls import() again when it renders still creates a sequential request that shows up in the waterfall.

Understand every bug

Uncover frustrations, understand bugs and fix slowdowns like never before with OpenReplay — self-hosted, with full data ownership.

Star on GitHub

We use cookies to improve your experience. By using our site, you accept cookies.