Attributing Bundle Bloat with Source Maps

A chunk report tells you vendor-3f8a1c.js weighs 312 KB gzipped. It does not tell you that 96 KB of that is a second, orphaned copy of a charting library, or that a single icon set accounts for a quarter of the file. Bundle-level totals are useful for tripping a CI budget, but they cannot direct an engineer to the specific import statement responsible for the regression. Source maps close that gap: they carry the original file paths and line/column offsets that let a tool reconstruct, byte for byte, which original module produced which range of the minified output. On a mid-size chunk (200–400 KB uncompressed) this typically surfaces 2–3 concrete, fixable causes — a duplicate dependency, an unshaken barrel import, or an unintentionally bundled dev-only library — within minutes rather than hours of manual grepping through node_modules.

This page covers the analysis half of that workflow: generating a map good enough to attribute bytes, running the attribution, and reading the result. It sits alongside the broader Bundle Analysis & Performance Budgets section, next to the treemap-based analyzer tooling and CI budget enforcement techniques covered there — source-map attribution is the technique you reach for once a budget gate or a treemap has already told you that a chunk is too big, and you need to know why.

Why chunk totals alone don’t explain bloat

A production bundler applies several transformations between source and shipped bytes: tree-shaking removes unused exports, minification renames identifiers and strips whitespace, and scope hoisting concatenates module bodies into shared function scopes. By the time a chunk lands in dist/, none of the original file boundaries survive in the JavaScript itself — only in the accompanying .map file, which records a compact mapping from generated positions back to original source locations and (optionally) the original source content.

Without that map, a 280 KB chunk is an opaque blob. With it, a byte-attribution tool can walk every mapping segment, sum the generated bytes that trace back to each original file, and roll those up by package. That rollup is what exposes patterns invisible in a flat total: a dependency that appears twice under two different resolved paths, a single deeply-nested module contributing 40% of a “utilities” chunk, or dead code that survived tree-shaking because of a missing sideEffects declaration. The deeper mechanics of why tree-shaking sometimes fails to remove that dead code — importing from a CommonJS build, or a barrel file forcing retention of the whole module — are covered in the advanced tree-shaking and dependency optimization pillar; this page assumes that context and focuses on using source maps to find where the surviving bytes actually live.

On a typical 300 KB uncompressed vendor chunk audited from a production React or Vue application, attributed bytes tend to fall into a recognisable shape:

Attribution category Typical share of chunk Common root cause
Largest single vendor package 35–50% Expected — a UI framework or charting library that legitimately belongs in the chunk
Second- and third-largest packages 20–30% Usually fine, but worth checking against the chunk’s intended purpose
Duplicate copies of one package at two versions 10–30% Unpinned transitive ranges, missing resolve.dedupe/resolve.alias
Retained dead code from unshaken exports 5–15% Missing or incorrect sideEffects, or a dependency that still ships CommonJS and needs converting to ESM before tree-shaking can see inside it
[unmapped] / no source < 5% (healthy) Loader chain not forwarding maps — investigate above that threshold

These figures are a starting frame, not a hard rule — a chunk built around a single heavy dependency (a rich-text editor, a mapping library) will legitimately skew toward the first row. The value of attribution is comparing your actual chunk against this shape and asking which row is abnormally large.

Generating an analysis-grade source map

You do not need a debugging-grade source map to run byte attribution — you need one with enough fidelity that every generated byte maps to a real original file. That is a lower bar than the full devtool-quality maps used for breakpoint debugging and Sentry symbolication, which the source map generation and debugging workflows guide covers in depth, including devtool trade-offs, chunk-graph routing, and production upload pipelines. Here, the goal is narrower: turn maps on just long enough to run the attribution, then turn them back off.

Webpack 5 — hidden-source-map for a one-off analysis build

// webpack.config.js — Webpack 5: analysis-only source map generation
module.exports = {
  mode: 'production',
  // hidden-source-map writes .map files to disk without a sourceMappingURL
  // comment, so the browser never fetches them — safe to run against a
  // production-shaped build without exposing source to end users
  devtool: 'hidden-source-map',
  optimization: {
    // Keep real production settings active so byte attribution reflects
    // what actually ships, not an unminified debug build
    minimize: true,
  },
};

Run it, then keep both the emitted .js and its sibling .js.map — attribution tools need the pair, not just one file.

# Webpack 5: build once with maps enabled, then locate the pair
npx webpack --config webpack.config.js
ls dist/assets/vendor-*.js dist/assets/vendor-*.js.map

Vite 5+ — build.sourcemap for the same purpose

// vite.config.ts — Vite 5+: analysis-only source map generation
import { defineConfig } from 'vite';

export default defineConfig({
  build: {
    // true emits separate .map files next to each chunk; sufficient for
    // byte attribution without inlining source into the shipped JS
    sourcemap: true,
  },
});
# Vite 5+: build once with maps enabled, then locate the pair
npx vite build
ls dist/assets/*.js dist/assets/*.js.map

Both configurations are deliberately the minimal case — a single boolean or string flag. If your goal is production error tracking rather than a one-off attribution pass, use hidden-source-map (Webpack) or sourcemap: 'hidden' (Vite) permanently and route the maps to an error tracker instead, per the dedicated source-map debugging guide linked above. On the Vite side, map accuracy also depends on how cleanly plugins forward positions through the Vite module graph during dependency pre-bundling — a plugin that transforms a module without passing its incoming map through will produce an attribution gap for that file specifically.

Running the attribution

Source map byte-attribution pipeline Left to right: a minified JS chunk and its .map file both feed into an attribution tool such as source-map-explorer, which walks every mapping segment and sums generated bytes per original module. The output is a treemap where box area is proportional to attributed byte size, with two boxes for a duplicated dependency highlighted to show the same package appearing at two different resolved paths. vendor-3f8a1c.js 312 KB minified vendor-3f8a1c.js.map mappings + sources[] source-map-explorer walks mapping segments sums bytes per file Attribution treemap react-dom 128 KB chart-lib v3.2.0 54 KB chart-lib v3.0.1 42 KB date-fns 38 KB app modules 28 KB Two chart-lib boxes at different versions → duplicate dependency signature

source-map-explorer is the reference CLI for this workflow: it takes a bundle file and its map, walks every mapping in the map’s mappings field, and attributes bytes in the generated file back to entries in the map’s sources array. The result renders as an interactive treemap by default, where box area is proportional to attributed size — but it can also emit machine-readable JSON, which matters for CI (covered in the related guide below).

# Run byte attribution against a single vendor chunk
npx source-map-explorer dist/assets/vendor-3f8a1c.js dist/assets/vendor-3f8a1c.js.map

The default output opens an HTML treemap in your browser. Two things to look for immediately:

  • Uneven concentration — a handful of boxes accounting for the majority of the area. On most chunks, the top 3–4 attributed modules explain 60–75% of total bytes.
  • Repeated package names at different resolved paths — the strongest signal of a duplicate dependency, shown in the diagram above as two chart-lib boxes at different version tags. This is the exact failure mode covered in finding duplicate dependencies in a bundle.

For the full CLI flag reference — targeting a specific chunk with a glob, exporting --json for automated diffing, and generating a standalone --html report for sharing — see analyzing chunks with source-map-explorer.

Attribution treemaps vs. analyzer treemaps: which one first

Source-map attribution and stats-based analyzers such as webpack-bundle-analyzer or rollup-plugin-visualizer both render treemaps, and the two are frequently confused for the same tool. They answer different questions. Visualizing bundle composition with analyzers reads the bundler’s own internal stats — a pre-minification view of which modules the bundler decided to include, useful for spotting an accidentally-imported package or an unexpectedly large route chunk during day-to-day development, without needing a .map file at all.

Source-map attribution is the follow-up step once that first pass has already told you a specific chunk is oversized and you need byte-level precision on the shipped, minified output rather than the bundler’s pre-minification accounting. In practice, most teams run an analyzer treemap continuously in CI for a fast visual signal, and reach for source-map attribution only when a chunk crosses a budget threshold and the analyzer’s module list isn’t specific enough to act on — for instance when it shows a dependency imported once but attribution reveals it was actually resolved twice at different versions.

Reading the JSON output for automation

The --json flag produces a flat list of original file paths mapped to attributed byte counts, which is what you want when a human isn’t reading the report directly:

# Emit machine-readable attribution for scripting or CI diffing
npx source-map-explorer dist/assets/vendor-*.js --json > attribution.json
// attribution.json — abbreviated shape
{
  "results": [
    {
      "bundleName": "dist/assets/vendor-3f8a1c.js",
      "files": {
        "node_modules/react-dom/cjs/react-dom.production.min.js": 131072,
        "node_modules/[email protected]/dist/index.esm.js": 55296,
        "node_modules/[email protected]/dist/index.esm.js": 43008,
        "node_modules/date-fns/esm/index.js": 38912
      },
      "totalBytes": 319488
    }
  ]
}

Because this is plain JSON, it can be diffed against a previous run to catch attribution regressions the moment a new dependency lands — the same principle as the byte-budget gates covered in enforcing performance budgets in CI, except scoped to per-module attribution rather than a single chunk total.

Framework integration: attributing lazy-loaded chunk bytes

Attribution gets more valuable, not less, once code is split across lazy boundaries — each async chunk gets its own map, and each can be attributed independently to catch a dependency that leaked into the wrong split point.

React — auditing a React.lazy chunk

// React 18 — lazy boundary whose chunk gets independently attributed
import { lazy, Suspense } from 'react';

// Attribution should show only report-building modules in this chunk —
// if react-dom or an unrelated vendor shows up here, it leaked across
// the split boundary and belongs in a shared chunk instead
const ReportBuilder = lazy(() => import('./ReportBuilder'));

export function AdminPanel() {
  return (
    <Suspense fallback={<p>Loading report builder…</p>}>
      <ReportBuilder />
    </Suspense>
  );
}
# Attribute only the ReportBuilder chunk after a production build
npx source-map-explorer "dist/assets/ReportBuilder-*.js"

Vue 3 — auditing a defineAsyncComponent chunk

// Vue 3 — async component boundary; attribution isolates its dependency cost
import { defineAsyncComponent } from 'vue';

// If attribution shows a large shared utility library inside this chunk,
// it was not deduplicated against the main entry — check manualChunks
const HeavyEditor = defineAsyncComponent(() => import('./HeavyEditor.vue'));

Attributing chunk-by-chunk this way is the fastest way to confirm that a route-level code splitting boundary is doing what it claims — that a lazy chunk contains only the modules unique to that route, and not a copy of something already present in the main bundle or a sibling chunk.

This matters more as an application accumulates lazy boundaries. A single unattributed 40 KB duplicate is a rounding error on a monolithic bundle, but the same duplicate repeated across six lazy route chunks — because each route independently imports a utility that was never promoted to a shared chunk — multiplies into a 240 KB aggregate cost spread invisibly across the whole application. Attribution run per-chunk, on a schedule or as part of a release checklist, is what catches that multiplication before it compounds further across additional routes.

Quantified impact of source-map-driven attribution

  • Diagnosis time — teams using treemap totals alone typically spend 2–4 hours per bloat investigation manually bisecting imports; byte attribution against a source map narrows this to 15–30 minutes by pointing directly at the offending file paths.
  • Duplicate dependency discovery — attribution surfaces duplicate package copies in roughly 1 out of every 3 vendor chunks audited on mid-size React/Vue applications with more than 40 direct dependencies, each duplicate typically costing 30–90 KB uncompressed.
  • False-positive reduction — because attribution works from the actual shipped, minified output rather than pre-minification stats, it eliminates the false positives that stats-based analyzers produce when scope hoisting or manualChunks reshuffles module boundaries after the stats snapshot is taken.
  • Fix targeting — attributed byte counts let you rank fixes by impact instead of guessing; a module attributed at 4 KB is not worth a sideEffects audit, but one attributed at 60 KB is.
  • Regression prevention — diffing --json attribution output in CI catches a new duplicate dependency or an unshaken import the moment it lands, rather than after it accumulates across several merges into an unmistakable but harder-to-isolate total.

Common pitfalls

Attributing against a stale or mismatched map

Failure mode: the treemap shows modules that no longer exist in your source tree, or bytes don’t sum close to the file size. Root cause: the .map file on disk corresponds to a previous build — a caching layer served an old map for a chunk whose hash didn’t change between builds because content-hash generation ran before the fix was applied. Diagnostic signal: [unmapped] in the treemap exceeds 15–20% of total bytes, or attributed file paths reference deleted files. Fix: delete the build output directory entirely before regenerating maps (rm -rf dist), rather than relying on incremental rebuilds, and confirm the chunk hash changed between runs.

Running attribution against a development build

Failure mode: attributed sizes are 3–6× larger than the real production chunk, and the treemap is dominated by framework internals (warning strings, PropTypes checks) that never ship to users. Root cause: development builds skip minification and dead-code elimination, so every byte — including debug-only code paths — is still present and gets attributed. Diagnostic signal: total attributed bytes far exceed the number reported by your CI size-budget gate for the same chunk. Fix: always attribute a mode: 'production' (Webpack) or default production vite build output, matching exactly what a real user downloads.

Ignoring the [unmapped] bucket

Failure mode: a meaningful share of a chunk’s bytes are attributed to [unmapped] or [no source] rather than any real file, and the investigation stalls because there’s nothing to click through to. Root cause: a loader or plugin in the transform chain (a CSS-in-JS runtime, an inline SVG loader, or a Babel plugin missing sourceMaps: true) emitted code without forwarding a mapping for it. Diagnostic signal: the [unmapped] box is unusually large relative to chunk size (above roughly 5–8%). Fix: check each loader/plugin in the chain for a sourcemap/sourceMaps option and enable it; the source map generation and debugging workflows guide covers loader-chain mapping fidelity in detail.

Treating attribution as a substitute for fixing the root cause

Failure mode: a large attributed module gets manually excluded from analysis rather than actually removed or deduplicated, so the bytes still ship — only the report looks cleaner. Root cause: attribution is a diagnostic step, not a size-reduction technique on its own. Fix: feed the finding back into the actual remediation — a resolve.dedupe/resolve.alias fix for duplicates, a sideEffects correction for dead code that survived tree-shaking, or a manualChunks/splitChunks boundary change for misplaced vendor code, as covered in vendor chunk isolation and third-party management.

Attributing a chunk that was produced with a different NODE_ENV than production

Failure mode: attribution shows two large, near-identical modules with names like index.development.js and index.production.js from the same package, and neither number matches what your CI budget gate reports. Root cause: some dependencies ship separate development and production entry files behind an NODE_ENV check, and a misconfigured build pipeline (a missing DefinePlugin replacement in Webpack, or an environment variable not forwarded to a Vite build step) can pull in the wrong one, or in rare monorepo setups, both. Diagnostic signal: the attributed file path contains .development. or dev in a chunk that is supposed to be a production artifact. Fix: verify process.env.NODE_ENV is statically replaced with "production" at build time — Webpack’s mode: 'production' does this automatically, but custom configs that disable DefinePlugin need it added back explicitly.

Verification workflow

  1. Confirm the map pairs correctly. ls dist/assets/*.js.map and check that every JS chunk you intend to attribute has a matching .js.map with the same hash suffix.
  2. Run attribution and check the unmapped share. [unmapped] should stay under roughly 5% for a healthy build; investigate the loader chain if it doesn’t.
  3. Cross-check against Chrome DevTools. Open DevTools → Sources on the deployed (or locally served) chunk, confirm the original file tree resolves, and spot-check that a large attributed module in the treemap corresponds to a real, current import in your codebase.
  4. Diff --json output against the previous release. A new entry, or a jump in an existing entry’s byte count, should map to an identifiable dependency bump or code change — if it doesn’t, re-run the build clean.
  5. Automate the check. Wire the JSON diff into the same CI stage that runs your performance budget gate so attribution regressions block a merge rather than surfacing weeks later in a routine audit.
  6. Re-run after the fix, not just before it. Attribution is only proof of a diagnosis until the fix lands; re-run the same source-map-explorer command against the post-fix build and confirm the flagged module’s attributed byte count actually dropped by the amount you expected — a resolve.dedupe rule that silently fails to match, for example, will leave the duplicate’s byte count unchanged even though the config looks correct.

Treat this checklist as a loop rather than a one-time pass: each production release that adds dependencies is a candidate for a fresh attribution run, particularly on chunks that sit close to their configured budget threshold.

FAQ

Can I attribute bundle bytes without generating a source map?

Not reliably. Minified identifiers and concatenated module scopes make it impossible to tell which original file a given byte range came from without a map. Bundle analyzers that skip the map can only show you which entry point pulled in a dependency, not how many bytes of the final minified chunk that dependency actually occupies.

Does generating a source map for analysis slow down my production build?

Only if you leave the map enabled permanently. Generate it as a one-off diagnostic run — a CI job or a local build with sourcemaps forced on — attribute the bloat, apply the fix, then return devtool or build.sourcemap to your normal production setting. A single extra build with maps enabled typically costs 15–40% more build time but does not affect the shipped bundle.

Why does source-map-explorer show a module I already deleted from my code?

The map may be stale relative to the chunk. This happens when a build cache serves an old .map file for a chunk hash that changed, or when a transitive dependency still imports the module internally even though your own code no longer does. Re-run a clean build and check whether the module still appears in the dependency graph before assuming the tool is wrong.

How is source-map-explorer different from webpack-bundle-analyzer?

webpack-bundle-analyzer reads Webpack’s internal stats.json and shows module sizes as the bundler understood them before minification. source-map-explorer works from the final minified output and its source map, so it reflects the bytes actually sent over the network — including cases where minification, scope hoisting, or manualChunks reshuffled boundaries that the stats output does not capture.