Skip to main content
fast frontend

Analyzing Bundle Composition

5 min read Chapter 8 of 33

Analyzing Bundle Composition

The Symptom

The e-commerce platform’s vendor chunk is 142KB gzipped. The team knows it is large but does not know what is inside it. “React and some libraries” is the level of understanding. Without knowing which libraries contribute which bytes, optimization is guesswork.

The Cause

Bundlers combine hundreds of node_modules into opaque chunks. The file name vendor-s9t0u1.js reveals nothing about its contents. Source maps contain the mapping from bundled output back to original modules, but reading raw source maps is impractical.

Bundle analysis tools parse source maps and produce treemap visualizations showing each module’s contribution to the bundle. These visualizations turn 142KB of opaque JavaScript into an actionable inventory.

The Baseline

Before analysis, the team’s mental model of the vendor chunk:

  • React: “probably 40KB?”
  • React Router: “maybe 15KB?”
  • “Other stuff”

After analysis, the actual breakdown:

ModuleGzipped Size% of Vendor
react-dom42.1 kB29.6%
recharts31.2 kB22.0%
d3 (recharts dep)18.7 kB13.2%
lodash14.3 kB10.1%
react8.4 kB5.9%
react-router-dom9.2 kB6.5%
zod8.8 kB6.2%
Other9.3 kB6.5%

recharts plus its d3 dependency account for 35.2% of the vendor chunk. This charting library is only used on the inventory dashboard. It should not be in the shared vendor chunk at all.

lodash at 14.3KB is the CommonJS version. None of its tree-shaking-friendly lodash-es counterpart is used. The application imports 4 functions from lodash, but ships the entire library.

The Fix

Install and configure the analysis tools:

npm install --save-dev source-map-explorer rollup-plugin-visualizer

For source-map-explorer, enable source maps in the production build:

// vite.config.ts
export default defineConfig({
  build: {
    sourcemap: true, // Required for source-map-explorer
    // ...
  },
});

Run the analysis:

npx source-map-explorer dist/assets/vendor-*.js --json > bundle-report.json
npx source-map-explorer dist/assets/vendor-*.js --html bundle-report.html

For inline visualization during the build, use rollup-plugin-visualizer:

// vite.config.ts
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import { visualizer } from "rollup-plugin-visualizer";

export default defineConfig({
  plugins: [
    react(),
    visualizer({
      filename: "bundle-stats.html",
      open: false,
      gzipSize: true,
      brotliSize: true,
      template: "treemap",
    }),
  ],
  build: {
    sourcemap: true,
  },
});

The gzipSize: true option shows compressed sizes, which are the sizes that matter for transfer. Uncompressed sizes are misleading because highly repetitive code (like lodash’s many similar utility functions) compresses well, making their transfer cost lower than their raw size suggests.

For detecting duplicate dependencies (the same library bundled at different versions):

// scripts/check-duplicates.ts
import * as fs from "fs";

interface BundleModule {
  name: string;
  size: number;
}

interface BundleReport {
  results: Array<{
    files: Record<string, { size: number }>;
  }>;
}

function findDuplicates(reportPath: string): Map<string, string[]> {
  const report: BundleReport = JSON.parse(fs.readFileSync(reportPath, "utf-8"));

  const moduleVersions = new Map<string, string[]>();

  for (const result of report.results) {
    for (const filePath of Object.keys(result.files)) {
      // Extract package name from node_modules path
      const match = filePath.match(/node_modules\/(@[^/]+\/[^/]+|[^/]+)/);
      if (!match) continue;

      const packageName = match[1];
      const versionMatch = filePath.match(
        /node_modules\/[^/]+(?:\/[^/]+)?\/.*?(\d+\.\d+\.\d+)/,
      );
      const version = versionMatch?.[1] ?? "unknown";

      const existing = moduleVersions.get(packageName) ?? [];
      if (!existing.includes(version)) {
        existing.push(version);
        moduleVersions.set(packageName, existing);
      }
    }
  }

  const duplicates = new Map<string, string[]>();
  for (const [pkg, versions] of moduleVersions) {
    if (versions.length > 1) {
      duplicates.set(pkg, versions);
    }
  }

  return duplicates;
}

const duplicates = findDuplicates("bundle-report.json");
if (duplicates.size > 0) {
  console.error("Duplicate dependencies detected:");
  for (const [pkg, versions] of duplicates) {
    console.error(`  ${pkg}: ${versions.join(", ")}`);
  }
  process.exit(1);
}

On the e-commerce platform, this script detected:

  • tslib at versions 2.4.0 and 2.6.0 (7.2KB duplicated)
  • @babel/runtime at 7.21.0 and 7.23.0 (12.4KB duplicated)

Deduplication via npm dedupe and explicit version pinning in package.json resolved both.

The Proof

After the analysis-driven optimizations:

  1. recharts moved from vendor chunk to a dashboard-specific chunk: vendor chunk reduced by 49.9KB (35.2%).
  2. lodash replaced with lodash-es and direct imports: vendor chunk reduced by 11.8KB (only 2.5KB of the 4 used functions remain).
  3. Duplicate dependencies resolved: 19.6KB eliminated.
MetricBeforeAfterDelta
Vendor chunk142 kB72 kB-70 kB (-49%)
Homepage total JS212 kB150 kB-62 kB (-29%)
Homepage LCP (Moto G Power)4.2s3.1s-1,100ms
Homepage TBT1,800ms980ms-820ms

The 1,100ms LCP improvement came primarily from reduced JavaScript parse time. The 70KB vendor chunk reduction eliminated ~210ms of parse time on the throttled CPU profile, and the cascading effect of earlier parse completion allowed the LCP image to start rendering sooner.

The CI bundle size gate now tracks the vendor chunk at its new 72KB size with a budget of 85KB, preventing the recharts migration from being accidentally reverted.

The Trade-off

Source maps in production builds expose your source code structure. If you deploy source maps to your production CDN, anyone with DevTools can inspect your original TypeScript source. The standard mitigation: generate source maps during CI for analysis but do not include them in the deployment artifact, or upload them to an error tracking service (Sentry, Datadog) that uses them privately.

The rollup-plugin-visualizer adds 2-3 seconds to the build. Running it only in CI (via an environment variable check) avoids slowing down local development builds.

Bundle analysis is a snapshot. It shows the current state but does not prevent drift. The CI bundle size gate from Chapter 2 provides the prevention layer. Analysis tells you what to fix. The gate ensures it stays fixed.