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.
Table of contents
- 1. Why bundle size is an audit process, not a one time topic
- 2. Bundle analysis with vite-plugin-visualizer and source map explorer
- 3. Defining bundle size budgets and enforcing them in CI
- 4. Tracking down duplicate dependencies and version conflicts
- 5. Systematically auditing route based code splitting
- 6. Tree shaking: why it often doesn't kick in
- 7. Dynamic imports for heavy libraries
- 8. Continuous monitoring in pull requests
- 9. Analysis tools at a glance
- 10. Summary
- 11. FAQ
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.