Bundle Analysis with Rollup and Vite: Understand and Optimize JavaScript Bundles
AI generated
JS
() =>
JavaScript · Rollup · Vite · Performance · Build Tools
Bundle Analysis with Rollup and Vite
Understand bundles, optimize them, keep them under control

A 2 MB JavaScript bundle is not rare, but it is almost always avoidable. Bundle analysis with Rollup and Vite makes invisible problems visible: tree-shaking gaps, duplicate dependencies, unnecessary polyfills, and provides the foundation for targeted optimization.

13 min read Rollup · Vite · Tree Shaking · Code Splitting · CI Budget Rollup 4.x · Vite 5.x · Node.js 18+

1. Why bundle analysis is critical

JavaScript bundle size is one of the most direct factors influencing a web application's load time. Every kilobyte of JavaScript has to be downloaded, parsed and compiled before the first interaction becomes possible. Lighthouse and Core Web Vitals measure this time directly: Time to Interactive (TTI) and Total Blocking Time (TBT) drop proportionally to the bundle size saved. A bundle analysis with Rollup or Vite shows which parts of the bundle are actually valuable and which are being dragged along unnecessarily.

The surprising result of a first bundle analysis in a project that has grown over time: often a handful of large packages account for most of the bundle, date libraries like Moment.js with complete locale files, UI component libraries that get imported largely but used barely at all, or polyfills for browsers the target audience stopped using long ago. Without a visual bundle analysis these problems remain invisible, because npm ls and package.json only show the packages, not their compiled size in the actual bundle.

2. Setting up rollup-plugin-visualizer

rollup-plugin-visualizer is the standard tool for bundle analysis in Rollup projects. After the build it generates an interactive treemap report as an HTML file that shows how large each module is in the final bundle, in three representations: treemap (nested by module hierarchy), sunburst (radial circle) and network (dependency graph). It is installed with npm install -D rollup-plugin-visualizer and registered in the Rollup configuration.

The visualizer offers three size modes: stat (uncompressed size before the bundling process), parsed (size after bundling, before minification) and gzip (simulated gzip-compressed size). For performance decisions the gzip mode is the most relevant, because it comes closest to the size actually transferred. The difference can be substantial: a lodash chunk that appears as 50 KB under parsed might only be 15 KB after gzip.


// rollup.config.js: Bundle analysis with rollup-plugin-visualizer
import { visualizer } from 'rollup-plugin-visualizer';
import { defineConfig } from 'rollup';

export default defineConfig({
  input: 'src/main.js',
  output: {
    dir: 'dist',
    format: 'es',
    chunkFileNames: '[name]-[hash].js',
  },
  plugins: [
    // Only run visualizer in analysis mode to avoid bloating normal builds
    process.env.ANALYZE && visualizer({
      filename: 'dist/bundle-report.html',
      open: true,          // auto-open browser after build
      gzipSize: true,      // show gzip-estimated sizes
      brotliSize: true,    // show brotli-estimated sizes
      template: 'treemap', // treemap | sunburst | network | list | raw-data
    }),
  ].filter(Boolean),
});

// Run analysis: ANALYZE=1 rollup -c
// Or add to package.json:
// "analyze": "ANALYZE=1 rollup -c"

3. Bundle analysis in Vite projects

Vite internally relies on Rollup for production builds, which is why rollup-plugin-visualizer can be used directly in the Vite configuration. Alternatively there is vite-bundle-visualizer as a standalone tool: it internally calls vite build and produces a visualizer report directly, without needing to touch the Vite configuration. That is ideal for quick one-off analyses without permanently modifying the project.

Vite additionally offers the built-in option build.rollupOptions.output.manualChunks, which lets you deliberately determine which modules land in which chunks. Without this configuration, Rollup decides autonomously about chunk splitting, which can lead to suboptimal results for large dependency trees. A typical optimization: putting all large third-party libraries into a separate vendor chunk that changes less often and therefore stays in the browser cache longer than the application code.


// vite.config.js: Bundle analysis and chunk optimization
import { defineConfig } from 'vite';
import { visualizer } from 'rollup-plugin-visualizer';

export default defineConfig({
  build: {
    rollupOptions: {
      output: {
        // Manual chunk splitting for better caching
        manualChunks(id) {
          // Large UI framework in its own chunk
          if (id.includes('node_modules/vue') || id.includes('node_modules/@vue')) {
            return 'vue-vendor';
          }
          // Chart library (heavy) in dedicated chunk
          if (id.includes('node_modules/chart.js') || id.includes('node_modules/echarts')) {
            return 'charts';
          }
          // All other node_modules in vendor chunk
          if (id.includes('node_modules')) {
            return 'vendor';
          }
        },
        // Deterministic chunk names for better cache invalidation
        chunkFileNames: 'assets/[name]-[hash].js',
        entryFileNames: 'assets/[name]-[hash].js',
        assetFileNames: 'assets/[name]-[hash][extname]',
      },
    },
  },
  plugins: [
    process.env.ANALYZE === 'true' && visualizer({
      filename: 'dist/bundle-analysis.html',
      open: true,
      gzipSize: true,
      template: 'treemap',
    }),
  ].filter(Boolean),
});
// ANALYZE=true vite build

4. Recognizing and fixing tree-shaking issues

Tree shaking, the automatic removal of unused code by Rollup, only works under certain conditions. It works exclusively with ES modules (import/export), not with CommonJS (require()). It does not work when a module has side effects that are not declared. It does not work when an object is imported as a whole instead of individual named exports. Bundle analysis makes visible when tree shaking is not taking effect: an entire library shows up in the bundle even though only one function from it is used.

A common tree-shaking problem in bundle analysis: Lodash. import _ from 'lodash' or import { debounce } from 'lodash' pulls in the entire Lodash bundle (about 530 KB unzipped), because Lodash uses the CommonJS format and does not allow tree shaking. The fix: import debounce from 'lodash/debounce' (direct file path) or switching to lodash-es, which is ES-module compatible. Another common source: import * as Icon from '@heroicons/react', which imports all Heroicons instead of only the ones needed.

5. Code splitting: splitting chunks sensibly

Code splitting is the most important technique for reducing the initial bundle size. Instead of a single JavaScript file, the build tool produces multiple chunks, of which the browser only loads the one it needs on first load. Rollup and Vite support two kinds of code splitting: automatic splitting on dynamic import() calls and manual splitting via manualChunks configuration.

Bundle analysis helps identify suboptimal code splitting: when many small chunks are created (for example through overly aggressive manual splitting or many small dynamic imports), you end up with unnecessarily many HTTP requests and browser preloading becomes inefficient. When a single chunk is very large, it blocks loading. The balance: third-party libraries in stable, rarely changing chunks; route-specific code in its own chunks for lazy loading; shared utilities in shared chunks used by several routes in parallel.


// Advanced manualChunks strategy based on bundle analysis findings
// Use after visualizer reveals inefficient chunk distribution

export default defineConfig({
  build: {
    rollupOptions: {
      output: {
        manualChunks(id) {
          // Route-level splitting (each route loads its own chunk)
          if (id.includes('/pages/dashboard/')) return 'page-dashboard';
          if (id.includes('/pages/checkout/'))  return 'page-checkout';
          if (id.includes('/pages/admin/'))     return 'page-admin';

          // Heavy optional features (only loaded when needed)
          if (id.includes('node_modules/monaco-editor')) return 'monaco';
          if (id.includes('node_modules/pdf-lib'))       return 'pdf';

          // Stable third-party: long-lived cache, rarely changes
          if (id.includes('node_modules')) return 'vendor';
        },
      },
    },
    // Warn when any chunk exceeds 500 KB (before gzip)
    chunkSizeWarningLimit: 500,
  },
});

// After building, verify chunks with:
// ls -lh dist/assets/ | sort -k5 -h
// Large unexpected chunks = manualChunks needs adjustment

6. Finding duplicate dependencies

A common cause of unnecessary bundle size in bundle analysis: duplicate dependencies. This happens when two packages bring different versions of the same dependency that cannot be deduplicated, because the versions are semantically incompatible. In the visualizer the library then shows up multiple times in the bundle, under different chunk paths or version directories. A typical example: React v17 and v18 in the bundle at the same time, when an old library still points to React v17.

The npm ls tool shows the complete dependency tree structure and makes duplicate versions visible. npm dedupe or pnpm dedupe attempts to resolve duplicates. In Vite projects you can set resolve.dedupe in the configuration to ensure a package is always resolved from a single source. For stubborn duplicates, when a library explicitly requires a different version, upgrading the problematic library is usually the only clean solution.

7. Dynamic imports and lazy loading

Dynamic imports (import('./module.js')) are the most direct way to move code out of the initial bundle. Rollup and Vite recognize dynamic imports and automatically create separate chunks for each dynamically imported module. The browser only loads these chunks when the dynamic import is actually invoked, not on the page's initial load.

In the bundle analysis after introducing dynamic imports, it is important to check whether the chunks are actually produced as separate files. Under certain circumstances Rollup can inline chunks if they are found to be too small or too tightly coupled to the main chunk. The output.inlineDynamicImports flag must be set to false (the default). Chunk names should also be meaningful: /* webpackChunkName: "feature-x" */ does not work in Rollup/Vite, instead you use rollupOptions.output.chunkFileNames or name the dynamically imported module accordingly.

8. Enforcing a bundle budget in CI

Bundle analysis is most valuable when it is not just performed manually once, but runs as an automated step in the CI pipeline. Several tools allow defining a bundle budget: if the build exceeds a defined limit, the pipeline fails. This prevents new dependencies or careless imports from bloating the bundle unnoticed.

Rollup writes bundle statistics into the bundle object of the generateBundle hook. A simple script can read the chunk sizes after the build and compare them against defined limits. Vite 5.x offers build.chunkSizeWarningLimit as a simple warning threshold. For more precise budget checks, the bundlewatch tool is a good fit: it shows, via a PR comment, exactly how many KB a bundle grew through a commit, a strong signal for code reviewers who do not run build tools on their local machine.


// ci-bundle-check.js: Fail CI if any chunk exceeds size limits
// Run after: vite build --reporter=json
import { readFileSync, readdirSync, statSync } from 'node:fs';

// Budget definitions (in bytes, before gzip)
const BUDGETS = {
  'vendor': 300 * 1024,   // 300 KB, stable third-party code
  'main':   100 * 1024,   // 100 KB, app entry point
  'page-':  80  * 1024,   // 80 KB per page chunk
  default:  150 * 1024,   // 150 KB fallback for all other chunks
};

const distDir = 'dist/assets';
const files = readdirSync(distDir).filter(f => f.endsWith('.js'));

let failed = false;

for (const file of files) {
  const size = statSync(`${distDir}/${file}`).size;

  // Find matching budget key
  const budgetKey = Object.keys(BUDGETS).find(k => file.startsWith(k)) ?? 'default';
  const limit = BUDGETS[budgetKey];

  const kb = (size / 1024).toFixed(1);
  const limitKb = (limit / 1024).toFixed(0);

  if (size > limit) {
    console.error(`BUDGET EXCEEDED: ${file} is ${kb}KB (limit: ${limitKb}KB)`);
    failed = true;
  } else {
    console.log(`OK: ${file}: ${kb}KB / ${limitKb}KB`);
  }
}

if (failed) process.exit(1);

9. Rollup vs. Vite bundle output compared

Although Vite internally uses Rollup for production builds, the out-of-the-box results differ. Vite has its own defaults for code splitting, chunk naming and CSS-in-JS handling that differ from a plain Rollup setup. Bundle analysis of both tools reveals these differences: Vite by default produces more aggressive code splitting with more, smaller chunks; a plain Rollup setup by default produces fewer, larger chunks.

Property Rollup (direct) Vite (Rollup-based) Recommendation
Analysis tool rollup-plugin-visualizer visualizer or vite-bundle-visualizer Both use the same visualizer
Default splitting Conservative More aggressive (more chunks) Configure manualChunks in both
Tree shaking Fully configurable Fully configurable Equally good, both use Rollup
CSS handling Plugin needed Built in Vite for full-stack apps
Build speed Slower (JS bundler) Faster (Rolldown migration) Vite for developer experience

Bundle analysis is equally meaningful in both tools, because the same visualizer is used. The workflow is always the same: produce a build, open the visualizer report, identify the largest modules, fix tree-shaking issues, adjust code splitting, rebuild, compare. The iterative approach, analyze the build, optimize, analyze again, is more effective than any single measure on its own.

Mironsoft

Frontend performance, build optimization and Core Web Vitals

JavaScript bundle too large and load time too high?

We analyze your bundle with rollup-plugin-visualizer, identify tree-shaking gaps, duplicate dependencies and misconfigured code splitting, and deliver a concrete optimization plan.

Bundle audit

Visualizer analysis, tree-shaking check, duplicate detection and chunk assessment

Optimization

manualChunks strategy, dynamic imports, library migration and polyfill cleanup

CI integration

Setting up a bundle budget in the pipeline, bundlewatch integration and PR comments for size changes

10. Summary

Bundle analysis with Rollup and Vite makes invisible bundle problems visible and is the first step of any substantial performance optimization. rollup-plugin-visualizer, directly in Rollup or as a plugin in Vite, produces interactive treemap reports that show which modules take up how much space in the bundle. The three most common findings: failed tree shaking due to CommonJS dependencies, misconfigured code splitting that produces either too few or too many chunks, and duplicate dependencies caused by version conflicts.

The optimization strategy follows a clear sequence: analyze, identify the largest modules, fix tree shaking through correct import syntax, configure manualChunks for strategic code splitting, replace heavy libraries with lighter alternatives, and introduce dynamic imports for rarely used features. The bundle budget in CI, a script that fails the build when limits are exceeded, ensures these optimizations are not undone by future development. Bundle analysis is not a one-time task but a continuous process.

Bundle Analysis with Rollup and Vite: The Essentials at a Glance

Setting up the tool

npm install -D rollup-plugin-visualizer. As a plugin in Rollup/Vite. ANALYZE=1 as an env variable. gzipSize: true for realistic size figures.

Fixing tree shaking

import { debounce } from 'lodash/debounce' instead of all of lodash. lodash-es instead of lodash for ES modules. No import * as when individual exports suffice.

Code splitting

manualChunks for vendor/app/page separation. Dynamic import() for routes and optional features. chunkSizeWarningLimit as an early warning system.

CI budget

Check bundle sizes after the build and fail the pipeline when exceeded. bundlewatch for PR comments with size changes per commit.

11. FAQ: Bundle Analysis with Rollup and Vite

1What is bundle analysis and why does it matter?
Visualizes which modules take up how much space in the bundle. Essential for TTI and Core Web Vitals. Tree-shaking gaps and unnecessary dependencies are invisible without visualization.
2Installing rollup-plugin-visualizer?
npm install -D rollup-plugin-visualizer. As a plugin in rollup.config.js or vite.config.js. Only enable at ANALYZE=1 so normal builds are not slowed down.
3When does tree shaking fail?
With CommonJS (require). With import * as ... instead of named exports. With packages that have undeclared side effects. Solution: prefer lodash-es, direct paths, ES-module packages.
4What is manualChunks?
rollupOptions.output.manualChunks determines which modules land in which chunks. node_modules to vendor. Heavy libs to their own chunks. Routes to page chunks for lazy loading.
5Finding duplicate dependencies?
In the visualizer: the same library appears multiple times. CLI: npm ls <package>. Solution: npm dedupe or configure resolve.dedupe in Vite. Or upgrade the problematic library.
6stat vs. parsed vs. gzip in the visualizer?
stat: before bundling. parsed: after bundling, before minification. gzip: simulated transfer size, most relevant for performance decisions.
7Implementing a bundle budget in CI?
After the build, compare chunk file sizes against limits, process.exit(1) on overrun. Or bundlewatch for PR comments with size changes per commit.
8When to use dynamic imports?
Router views, rarely used features (PDF export, charts), admin areas, heavy libraries. import('./module') automatically creates a separate chunk.
9Vite vs. Rollup for bundle analysis?
Vite uses Rollup internally, same visualizer. Vite has its own defaults (more aggressive splitting, built-in CSS). manualChunks works identically in both.
10How much can bundle analysis save?
In unoptimized projects, often 40 to 70 percent. Moment.js to date-fns: 200 KB. Lodash to lodash-es: 300+ KB. Route splitting: 60 to 80 percent of the initial load avoided.