EmpireUI
Get Pro
← Blog8 min read#bundle size#performance#webpack

Bundle Size Analysis in React: webpack-bundle-analyzer, rollup-visualizer

Stop shipping mystery megabytes. Learn to use webpack-bundle-analyzer and rollup-visualizer to find exactly what's bloating your React app and cut it.

colorful abstract data visualization chart on dark background

Why Your Bundle Size Matters More Than You Think

Every kilobyte you send to the browser is a kilobyte the user's network has to download before your app renders. On a fast 5G connection that sounds trivial. On a 4G phone in rural Ohio — or a café in rural France — a 2MB JavaScript bundle can mean a 6+ second delay before anything interactive shows up on screen. That's users leaving.

Google's Core Web Vitals data from 2024 showed that every additional 100KB of uncompressed JS added roughly 350ms to Time to Interactive on median mobile hardware. That compounds fast. An unchecked React app that starts at 300KB gzipped and accumulates dependencies over six months can easily balloon past 1MB — and you often won't notice because it happens gradually.

Honestly, most teams don't look at their bundle composition until something breaks. A performance audit, a Lighthouse score that tanks, a stakeholder screaming about mobile conversion rates. That's backwards. Bundle analysis should be part of your regular dev workflow, the same way linting is. It takes maybe five minutes to set up, and the payoff is massive.

Worth noting: bundle size and runtime performance are related but different problems. A 200KB bundle of heavily animated canvas code can be slower than a 600KB bundle of static data. But large bundles have a floor cost — parse time — that hurts everyone regardless of what the code does.

Setting Up webpack-bundle-analyzer

If your project uses Webpack — Create React App, Next.js with the default config, or a custom Webpack 5 setup — webpack-bundle-analyzer is the tool you want. It generates an interactive treemap where each rectangle is a module, sized by its contribution to the final bundle. You can filter, zoom, and hover to see exact parsed/gzipped sizes.

Install it once as a dev dependency: ``bash npm install --save-dev webpack-bundle-analyzer # or yarn add -D webpack-bundle-analyzer ` For a **plain Webpack config**, add the plugin in webpack.config.js: `js const { BundleAnalyzerPlugin } = require('webpack-bundle-analyzer'); module.exports = { // ...your existing config plugins: [ new BundleAnalyzerPlugin({ analyzerMode: 'static', // writes report.html instead of opening a server openAnalyzer: false, // set true locally if you want auto-open reportFilename: 'bundle-report.html', }), ], }; ``

For Next.js, the community package @next/bundle-analyzer wraps it cleanly. As of Next.js 14+ you configure it in next.config.js: ``js const withBundleAnalyzer = require('@next/bundle-analyzer')({ enabled: process.env.ANALYZE === 'true', }); module.exports = withBundleAnalyzer({ // your next config }); ` Then just run ANALYZE=true next build to generate the treemap. The enabled` flag keeps it out of your production build process.

The report opens in your browser and shows three views: Stat (before tree-shaking), Parsed (after bundling), and Gzipped (what the browser actually downloads). Gzipped is the number you care about for network budgets. Parsed is the number you care about for parse-time budgets. Look at both.

One more thing — the analyzer also shows you *chunk* structure. If you're code-splitting with React.lazy() and dynamic imports, you want to confirm your lazy chunks aren't accidentally including the same 80KB library three times. That's a very common bug, and the treemap makes it obvious at a glance.

rollup-visualizer for Vite Projects

Vite uses Rollup under the hood for production builds, so webpack-bundle-analyzer won't help you here. Instead, reach for rollup-plugin-visualizer. Same idea — interactive treemap, per-module sizes — but built for the Rollup plugin API that Vite exposes directly.

``bash npm install --save-dev rollup-plugin-visualizer ` In vite.config.ts: `ts import { defineConfig } from 'vite'; import { visualizer } from 'rollup-plugin-visualizer'; export default defineConfig({ plugins: [ visualizer({ filename: 'dist/stats.html', open: true, // auto-open after build gzipSize: true, // show gzipped sizes brotliSize: true, // show brotli sizes too template: 'treemap', // or 'sunburst', 'network' }), ], }); ` Run vite build and the visualizer drops stats.html in your dist folder (or wherever you set filename`).

The template option is worth playing with. treemap is the default and the most intuitive for spotting large modules. sunburst shows the same data as nested rings — some people find it easier to trace import hierarchies in that view. network is a dependency graph; it's chaotic for large projects but useful for understanding why a specific module is included.

In practice, rollup-visualizer on a fresh Vite + React project usually shows a pleasantly small bundle — React itself is around 42KB gzipped as of React 18.3, and Vite's tree-shaking is aggressive. The problem shows up once you add a date library (hello, moment.js at 288KB gzipped), a full icon set, or a UI library that wasn't designed for tree-shaking. That's when the visualizer earns its keep.

Quick aside: if you're using Vitest for testing, the visualizer won't affect test runs — it only activates during vite build, not vite dev or vitest. So you can leave it in your config permanently without any dev-server overhead.

Reading the Treemap: What to Actually Look For

Opening the report for the first time is often a gut-punch moment. You see things like: your entire app logic takes up 80KB, but lodash is 530KB, or a single icon library is 400KB. The treemap makes these proportions visceral in a way that a dependency list in package.json never does.

Here's how to triage what you're looking at. Big rectangles with names you don't recognize are usually transitive dependencies — things your dependencies pulled in. Hover over them and trace the import path. Common culprits in React apps: moment, date-fns (when imported as a barrel), lodash (imported as a whole), @mui/icons-material (when not tree-shaken), and any polyfill bundle targeting browsers you dropped support for in 2023.

Duplicated modules are the sneakiest issue. If you see lodash appearing in your main chunk *and* in three lazy-loaded chunks, you have a shared dependency that isn't being deduplicated. In Webpack 5, check your optimization.splitChunks config. In Rollup/Vite, this usually means the same package appears in multiple places in your dependency tree at different semver versions — npm dedupe often fixes it.

Look for `node_modules` taking more than 70% of total parsed size. That's the threshold where you should seriously audit your dependencies before adding more. Check if those libraries have lighter alternatives — date-fns instead of moment, clsx instead of classnames, lucide-react instead of importing all of react-icons. For UI component work, Empire UI is built specifically to be tree-shakeable, so you only ship the components you actually use.

One specific pattern to hunt down: barrel files (index.ts files that re-export everything from a folder). Even with a bundler doing tree-shaking, barrel files can defeat it. If you import import { Button } from './components' and that index.ts re-exports 200 components, some bundlers will include more than you intended. Prefer direct imports: import { Button } from './components/Button'.

Common Culprits and How to Fix Them

moment.js — if you see this, replace it. As of 2022 the project itself recommends migration. date-fns v3 is fully tree-shakeable and covers ~95% of moment's API surface. Or use native Intl.DateTimeFormat for formatting if you don't need date math.

lodash — never import import _ from 'lodash'. Import individual functions: import debounce from 'lodash/debounce'. Or switch to lodash-es (the ES module build) which Vite/Rollup can properly tree-shake. The difference between a full lodash import and a specific function import is roughly 530KB vs 2KB gzipped.

Icon librariesreact-icons ships every icon family in one package. Even with tree-shaking, the dev experience pushes people toward barrel imports that pull in entire icon sets. Lucide React 0.400+ ships individual ESM files per icon and works beautifully with Vite's tree-shaking. If you're already using a design system like Empire UI, check out the built-in icon patterns before adding a third-party icon library at all. ``bash # instead of this (loads everything) import { FaHome, FaUser } from 'react-icons/fa'; # do this (Lucide, tree-shakeable) import { Home, User } from 'lucide-react'; ``

Unguarded polyfills — Babel configured to target > 0.5% browsers in 2026 will polyfill things modern browsers have natively supported for years. Audit your browserslist config. If you're dropping IE11 (you should be, it's been dead since 2022), tighten your targets to last 2 Chrome versions, last 2 Firefox versions, last 2 Safari versions and watch your polyfill chunk shrink.

CSS-in-JS runtime overhead — this isn't directly visible in a JS bundle analyzer but it matters. If you're using styled-components or Emotion, those ship a runtime style-injection engine. On a big app that engine alone can be 15-20KB gzipped. Tailwind CSS by contrast ships zero runtime — all styles are generated at build time. For UI component work, both approaches are valid, but be intentional about the tradeoff. The box shadow generator and gradient generator on Empire UI let you generate pure CSS that you paste directly — no runtime dependency.

Automating Bundle Size Checks in CI

Running a manual analysis once is good. Running it on every pull request is better. You want a CI check that fails if the bundle grows beyond a threshold — so regressions are caught before they merge, not after users notice.

bundlesize (npm package) or size-limit are the two main options. size-limit is the more modern choice with better ESM support. Config in package.json: ``json { "size-limit": [ { "path": "dist/assets/*.js", "limit": "250 KB" }, { "path": "dist/assets/*.css", "limit": "30 KB" } ], "scripts": { "size": "size-limit", "analyze": "size-limit --why" } } ` Run npm run size` in CI after your build step. It exits with a non-zero code if any path exceeds its limit, which fails the pipeline.

The --why flag on size-limit uses Webpack or Rollup under the hood to show you exactly which imports are contributing to the total — similar to the visualizer, but scriptable and diff-able in CI output. Very useful for PR comments that say "this PR added 42KB because it imported X".

For GitHub Actions, a typical step looks like: ``yaml - name: Check bundle size run: | npm run build npm run size ` If you want PR comments with the delta, the compressed-size-action` GitHub Action does that automatically — it posts a comment showing which files got bigger or smaller compared to the base branch. That's the kind of visibility that makes teams actually care about bundle size.

Look, the automation isn't complicated. The hard part is agreeing on budgets with your team and sticking to them. Start conservative — set your limit to 110% of your current bundle size — then tighten it sprint by sprint as you do cleanup work. What gets measured gets managed.

Connecting Bundle Size to Real Performance Gains

A smaller bundle isn't just a vanity metric — it translates directly to Lighthouse scores, Core Web Vitals, and ultimately conversion. Google's 2025 ranking data showed that sites with Total Blocking Time under 200ms had a 23% higher organic click-through rate than comparable sites with TBT above 600ms. JS bundle size is one of the primary drivers of TBT.

After you run the analyzer and do a first pass of cleanup, re-run Lighthouse in Chrome DevTools (or use PageSpeed Insights) and compare. A common result after removing one heavy date library and fixing icon imports: 400ms shaved off TBT, one full Lighthouse performance point gained. Not transformative on its own, but stacked with image optimization and page transitions that don't block rendering, it adds up fast.

For React apps specifically, also look at React.lazy() and Suspense for code-splitting. If you have a large chart library (like Recharts, which you can read about in the recharts guide) that only appears on one route, it has no business being in your initial bundle. Dynamic import it: ``tsx const ChartComponent = React.lazy(() => import('./ChartComponent')); // in your JSX <Suspense fallback={<Skeleton />}> <ChartComponent data={data} /> </Suspense> `` The visualizer will confirm the chart code moved to its own chunk after this change.

That said, don't over-split. Loading dozens of tiny chunks can introduce latency from HTTP round-trips that outweighs the parse-time savings. The sweet spot is usually splitting at the route level plus one or two component-level splits for genuinely large, conditionally-used features. Everything else stays in the main chunk.

Bundle analysis is one of those rare dev tasks where the ROI is almost always positive. An afternoon of cleanup — removing dead imports, switching one heavy library, enabling code splitting on two routes — can permanently reduce your app's weight by 30-40%. Your users on slower connections will feel that. And for UI components specifically, picking a tree-shakeable library from the start (like browsing Empire UI components) saves you the cleanup work entirely.

FAQ

What's the difference between webpack-bundle-analyzer and rollup-visualizer?

They do the same job — interactive treemap of your bundle composition — but for different bundlers. Use webpack-bundle-analyzer for Webpack-based projects (CRA, Next.js) and rollup-plugin-visualizer for Vite or plain Rollup projects.

How do I run bundle analysis without it affecting my production build?

Gate the plugin behind an environment variable: enabled: process.env.ANALYZE === 'true' for the Next.js analyzer, or a similar condition in your Vite config. That way it only runs when you explicitly trigger it, not on every CI build.

What's a healthy gzipped JS bundle size for a React app?

A reasonable target for the initial bundle is under 150KB gzipped, with additional lazy-loaded chunks for heavy features. React 18 itself is ~42KB gzipped, so you have roughly 100KB budget for your app code and core dependencies before you're in trouble territory.

Can I automate bundle size checks in CI so regressions don't slip through?

Yes — size-limit is the standard tool for this. Add size budgets to your package.json and run it after your build step in CI. It exits non-zero if any bundle exceeds its limit, blocking the merge.

Free components in 40 styles
React & Tailwind, copy-paste ready.
Browse →

Read next

Next.js Bundle Analyzer: Finding and Fixing Size IssuesAnalyzing Next.js Bundle: @next/bundle-analyzer in CIReact UI Library Bundle Size Compared: shadcn, MUI, Mantine, Empire UIPerformance in Design Systems: Bundle Size, Tree-Shaking, Lazy