Bundle Analysis and Code Splitting Audits for React
AI generated
</>
{ }
React · Bundle Analysis · Code Splitting · Performance
Bundle Analysis and Code Splitting Audits
keeping React apps lean over the long term

Bundle analysis in React apps is not a one time optimization project, it's a recurring audit process. Without visualizer tools, size budgets in CI and regular checks for duplicate dependencies, every bundle grows unnoticed until load times and Core Web Vitals suffer noticeably.

18 min read Vite Visualizer · Source Map Explorer CI Budgets · Dynamic Import

1. Why bundle size is an audit process, not a one time topic

Many teams run bundle analysis exactly once, shortly before a big release, optimize the most glaring issues, and then forget about the topic for months. The problem: every new dependency, every new component and every imported icon set contributes a little to the bundle size, and these small increments add up over a year into noticeably slower loading, without any single commit being clearly responsible.

Bundle analysis must therefore be understood as a continuous audit process, not a completed project. A one time cleanup brings short term improvement but doesn't prevent the same state from repeating itself within a few months, unless structural controls such as CI budgets get established. The following sections show concrete tools and processes for recurring bundle analysis that prevents regressions instead of fixing them after the fact.

The core of a good audit process for code splitting and bundle size consists of three building blocks: visibility through visualizer tools, hard limits through CI budgets, and a systematic approach to duplicate dependencies. All three get shown below with concrete configurations.

2. Bundle analysis with vite-plugin-visualizer and source map explorer

The first step of any bundle analysis is visibility: which module contributes how much to the final bundle size. For Vite based React projects, rollup-plugin-visualizer delivers an interactive treemap view, generated as an HTML file after every build, showing exactly how many kilobytes each dependency occupies in the final bundle.


// vite.config.ts — generate a bundle visualization after every build
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import { visualizer } from "rollup-plugin-visualizer";

export default defineConfig({
  plugins: [
    react(),
    visualizer({
      filename: "dist/stats.html",
      gzipSize: true,
      brotliSize: true,
      template: "treemap", // "sunburst" and "network" are also available
    }),
  ],
});

For Create React App projects or already built bundles without a fresh build, source-map-explorer is a good fit, reading existing source maps and producing the same kind of treemap directly from the dist directory. Both tools solve the same task for bundle analysis: they surface what otherwise stays hidden as an abstract total size in the network tab of developer tools, and allow targeting the largest individual items instead of optimizing blindly.

3. Defining bundle size budgets and enforcing them in CI

Visibility alone doesn't prevent a regression if nobody regularly looks at it. A bundle size budget turns this into an automated check: if a build exceeds a defined limit, the CI pipeline fails before the pull request can be merged. For bundle analysis in practice, bundlesize or the more modern size-limit is the common choice.


{
  "size-limit": [
    {
      "name": "Main bundle (gzip)",
      "path": "dist/assets/index-*.js",
      "limit": "180 KB",
      "gzip": true
    },
    {
      "name": "Checkout route chunk",
      "path": "dist/assets/checkout-*.js",
      "limit": "60 KB",
      "gzip": true
    },
    {
      "name": "Vendor chunk",
      "path": "dist/assets/vendor-*.js",
      "limit": "220 KB",
      "gzip": true
    }
  ]
}

A single call in the CI pipeline is then enough to check every pull request against these budgets: npm run build && npx size-limit. If a chunk exceeds its limit, the command exits with a non zero code, causing the CI step to fail and blocking the pull request from being merged until the bundle size is back within budget.

The decisive effect of these budgets for bundle analysis: a new, heavy dependency stands out immediately in the pull request, before it even gets merged, instead of only being discovered months later during an expensive follow up analysis. The limits should be set realistically based on the current state, with a bit of buffer, rather than arbitrarily low, to avoid constant false alarms.

4. Tracking down duplicate dependencies and version conflicts

A frequently overlooked bundle size driver, one that a superficial bundle analysis can easily miss, is duplicate versions of the same library in the bundle. This typically happens when two different dependencies each require their own, incompatible version of the same library as a peer dependency, causing the package manager to install both versions in parallel.

The treemap visualizer from section two usually already shows such duplicates visually as two separate blocks with the same name but different versions. Additionally, npm ls <package-name> helps list every installed version of a package across the entire dependency tree. For bundle analysis purposes, the rollup plugin rollup-plugin-duplicate-package-checker-plugin is also useful, failing the build as soon as multiple versions of the same package land in the output bundle. The fix is usually an overrides entry in package.json, forcing the package manager to use a single unified version.

5. Systematically auditing route based code splitting

Route based code splitting with React.lazy is one of the most effective levers against bloated initial bundles, but in grown codebases it's often applied consistently in some places and forgotten in others. A systematic audit checks every top level route of the application for whether it's actually loaded via lazy() or accidentally remained part of the main bundle.


// router.tsx — auditing route-level code splitting
import { lazy, Suspense } from "react";
import { createBrowserRouter } from "react-router-dom";

// Correct: each route is its own chunk, loaded on demand
const CheckoutPage = lazy(() => import("./pages/CheckoutPage"));
const AdminDashboard = lazy(() => import("./pages/AdminDashboard"));

// Anti-pattern found during an audit: direct import keeps this
// in the main bundle even though it is rarely visited
// import { ReportsPage } from "./pages/ReportsPage";
const ReportsPage = lazy(() => import("./pages/ReportsPage"));

export const router = createBrowserRouter([
  {
    path: "/checkout",
    element: (
      <Suspense fallback={<PageSkeleton />}>
        <CheckoutPage />
      </Suspense>
    ),
  },
  {
    path: "/admin",
    element: (
      <Suspense fallback={<PageSkeleton />}>
        <AdminDashboard />
      </Suspense>
    ),
  },
  {
    path: "/reports",
    element: (
      <Suspense fallback={<PageSkeleton />}>
        <ReportsPage />
      </Suspense>
    ),
  },
]);

During bundle analysis, such an anti-pattern shows up clearly in the treemap: a route rarely visited in everyday use, say an admin area or a reporting page, still appears in the main bundle and gets loaded by every visitor, regardless of whether they ever visit that route. Systematically auditing every route against this checklist is one of the most rewarding measures in any recurring bundle analysis.

6. Tree shaking: why it often doesn't kick in

Tree shaking is supposed to automatically remove unused code from the bundle, but only works reliably if certain conditions are met. The most common reason tree shaking doesn't work as expected during a bundle analysis is the import style: import _ from "lodash" pulls the entire library into the bundle, while import debounce from "lodash/debounce" only includes that single function.

A second, subtler reason is side effects in imported modules. Bundlers have to be conservative and may not remove code if importing it could theoretically trigger a side effect, such as a global registration. The "sideEffects": false field in a custom library's package.json explicitly signals to the bundler that unused exports can be removed safely. For bundle analysis purposes, it's worth specifically checking whether commonly used libraries such as icon sets or UI kits set this field correctly, since missing tree shaking especially in large icon libraries leads to unnecessarily bloated bundles.

7. Dynamic imports for heavy libraries

Some libraries are inherently heavy regardless of tree shaking, such as charting libraries, rich text editors or PDF generators. For these, a dynamic import is worthwhile, loading the library only when the corresponding functionality is actually used, instead of loading it on the initial page visit even if the functionality is never invoked.


// ChartWidget.tsx — loading a heavy charting library only when needed
import { useState, useEffect, type ComponentType } from "react";

interface ChartProps {
  data: number[];
}

export function ChartWidget({ data }: ChartProps) {
  const [ChartComponent, setChartComponent] = useState<ComponentType<ChartProps> | null>(null);

  useEffect(() => {
    // recharts (roughly 90 KB gzipped) is only fetched once this component mounts
    import("./RechartsWrapper").then((module) => {
      setChartComponent(() => module.default);
    });
  }, []);

  if (!ChartComponent) {
    return <div className="h-64 animate-pulse bg-slate-100 rounded-lg" />;
  }

  return <ChartComponent data={data} />;
}

For a thorough bundle analysis, it's worth listing every library above a certain size threshold, say 30 kilobytes gzipped, and asking whether each one is really needed on initial load. Charting libraries only visible on a dashboard tab, PDF export functionality only triggered on a button click, and rich text editors that only appear in the admin area are classic candidates for dynamic imports.

8. Continuous monitoring in pull requests

For bundle analysis to actually become a recurring process rather than a one time action, it needs to be integrated into the daily development workflow. A GitHub Actions bot that posts the bundle size change directly as a comment on every pull request makes the impact of a change immediately visible, without anyone having to manually open a report.


# .github/workflows/bundle-comment.yml
name: Bundle Size Comment

on:
  pull_request:
    branches: [main]

jobs:
  comment-bundle-diff:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0
      - uses: actions/setup-node@v4
        with:
          node-version: 20
      - run: npm ci
      - name: Compare bundle size against main
        uses: andresz1/size-limit-action@v1
        with:
          github_token: ${{ secrets.GITHUB_TOKEN }}
          script: "npm run build"

This automation is the most important step in turning bundle analysis from a sporadic exercise into a fixed part of code review. Reviewers see directly in the pull request whether a change grows the main bundle by 500 bytes or by 40 kilobytes, and can specifically ask about larger jumps whether a dynamic import or a smaller replacement for a new dependency would make sense.

9. Analysis tools at a glance

Different tools suit different purposes within a bundle analysis process. The table below arranges the key options by their primary use case.

Tool Purpose Runs in CI
rollup-plugin-visualizer Interactive treemap after every build Optional, usually used manually
source-map-explorer Treemap from existing source maps Optional, usually used manually
size-limit Enforces hard budgets per chunk Yes, blocks merges when exceeded
duplicate-package-checker Detects duplicate package versions Yes, as a build step
size-limit-action Posts size change as a PR comment Yes, primarily built for CI

In practice these tools complement each other: visualizer tools serve targeted, deep analysis during larger optimization rounds, while size-limit and duplicate checkers handle continuous monitoring in the CI pipeline. A complete bundle analysis strategy combines both categories, instead of relying on just one.

Mironsoft

Bundle analysis, code splitting and performance audits for React

Is your React bundle growing unnoticed from release to release?

We run a complete bundle analysis of your React app, identify duplicate dependencies and missing code splitting, and set up CI budgets that permanently prevent regressions.

Bundle audit

In depth analysis with visualizer tools and concrete optimization suggestions

Setting up CI budgets

Size budgets per chunk that automatically block regressions in pull requests

Code splitting rollout

Systematically retrofitting route based splitting and dynamic imports

10. Summary

Bundle analysis only unfolds its value as a recurring audit process, not as a one time cleanup action. Visualizer tools such as rollup-plugin-visualizer and source-map-explorer create the necessary visibility, while size-limit in the CI pipeline enforces hard limits before a regression can even get merged.

Route based code splitting with React.lazy, consistent tree shaking through correct import style, and dynamic imports for heavy libraries are the concrete technical levers that a bundle analysis typically uncovers. Anyone who integrates these checks automatically into pull requests prevents the creeping growth that, left unchecked, makes every React application noticeably slower over months.

Bundle Analysis and Code Splitting Audits at a Glance

Creating visibility

rollup-plugin-visualizer and source-map-explorer show as a treemap which module occupies how much bundle size.

Enforcing CI budgets

size-limit checks every pull request against defined limits and blocks merges when they're exceeded.

Duplicate dependencies

Version conflicts lead to duplicately bundled libraries, npm ls and duplicate checkers uncover them.

Route splitting and dynamic import

React.lazy per route and dynamic imports for heavy libraries noticeably reduce the initial bundle.

11. FAQ: Bundle Analysis and Code Splitting in React

1How often should bundle analysis run?
Continuously via CI budgets per pull request, plus deeper manual analysis every few months.
2Visualizer versus source map explorer?
Visualizer generates the treemap during the build, source map explorer reads existing source maps.
3How to set a size budget?
Base it on the current state with some buffer, not arbitrarily low.
4How to spot duplicate dependencies?
Two blocks with the same name, different versions, in the treemap. npm ls lists every installed version.
5Why doesn't tree shaking always work?
Often due to wrong import style or a missing sideEffects: false in package.json.
6When for a dynamic import?
For heavy, rarely used libraries such as charting libraries or PDF generators.
7What is route based code splitting?
Every route loaded as its own chunk via React.lazy, instead of staying in the main bundle.
8How to post size changes in a PR?
With a GitHub Action such as size-limit-action posting the result as a pull request comment.
9Is gzip enough to ignore bundle size?
No, compression reduces transfer, not the browser's parse and execution time.
10Which metrics suffer most?
Mainly time to interactive and interaction to next paint, due to long parse and execution times.