Reading a Bundle with source-map-explorer

When a chunk is too big, the first question is which module owns the bytes — and source-map-explorer answers it by walking the source map and attributing every output byte back to an original file. This guide runs it against a real Vite build, reads the output, and turns it into a scriptable JSON report you can assert on. It is the attribution technique from bundle analysis and size budgeting; for the parent theory see Core Concepts of Modern Bundling.

The problem this solves is that a minified production chunk is opaque. After Rollup (which Vite uses under the hood for production builds) has bundled, tree-shaken, renamed, and minified your modules into a single index-<hash>.js, the file is a wall of single-letter identifiers with no module boundaries left in it. You can see the file is 480 KB, but the file itself no longer tells you that 190 KB of that is a date library you imported for one format() call, or that a transitive dependency dragged in a second copy of a polyfill. The byte-to-module relationship was destroyed at bundle time, and the only surviving record of it is the source map — a JSON sidecar that encodes, per output segment, which original file and position it came from. source-map-explorer exists to read that record back and reconstruct the attribution the bundler threw away.

Without this step, size regressions are invisible until they hurt. A dependency bump that quietly doubles a package, a import _ from 'lodash' that pulls the whole library instead of lodash/debounce, a barrel file that defeats tree-shaking — none of these announce themselves. They land as a slightly slower Largest Contentful Paint weeks later, by which point the offending commit is buried. Attribution moves the diagnosis to the moment the bytes appear: you build, you look at the treemap, and the largest rectangle is the thing to fix. This sits at the very front of the size-budgeting workflow — you attribute first to find the target, then apply lazy-loading, deduplication, or replacement, then enforce the result with a budget so it cannot regress again.

Walking the source map to attribute output bytes source-map-explorer reads the emitted chunk and its source map, maps each output byte range back to the original source file, and produces a treemap where each rectangle's area is the bytes that module contributed to the chunk. Every output byte traced to a source file chunk.js + .mapemitted output walk the mapbyte range → source treemaparea = bytes a big rectangle is a big module — that is where to look first
Figure: the treemap's rectangle areas are output bytes, so the biggest box is literally the biggest contributor.

Prerequisites & reproducible setup

# Vite 5.4.x, Node 20+, source-map-explorer 2.5.3
npm create vite@latest sme-demo -- --template react-ts
cd sme-demo && npm install
npm install dayjs lodash          # a couple of libs to attribute
npx vite build --sourcemap        # emit maps so attribution works

The build writes dist/assets/index-*.js and a matching .map. Without --sourcemap (or build.sourcemap: true), source-map-explorer has nothing to trace and reports an error.

The --sourcemap flag is load-bearing and worth understanding rather than copying. It tells Rollup to emit a .map file next to each chunk and to append a //# sourceMappingURL=index-<hash>.js.map comment to the end of the chunk. source-map-explorer reads that comment to locate the map; if the comment is missing or points at a file that was not deployed, attribution fails. For a pure analysis run you do not need the highest-fidelity map — build.sourcemap: 'hidden' still writes the .map file but omits the trailing comment, which keeps maps out of production while leaving a file the tool can be pointed at explicitly. The one thing you cannot do is analyze a build that never emitted a map at all, because the byte-to-source relationship simply does not exist anywhere on disk.

Note that the map you analyze must come from the same build as the chunk. If you rebuild the chunk but reuse a stale .map, the segment offsets will not line up and attribution will be silently wrong — bytes get charged to the wrong module rather than erroring outright. Treat the chunk and its map as a single unit and regenerate both together.

The chunk and its map must both exist source-map-explorer needs both the emitted chunk and its adjacent .map file; building without sourcemap emits the chunk alone and the tool errors because there is no map to walk. index.js + index.js.map--sourcemap → works index.js onlyno map → errors no map file, no attribution
Figure: the .map next to the chunk is mandatory — build with source maps or the tool has nothing to read.

How it works under the hood

The attribution is not an estimate — it is an exact reconstruction from the map. A source map is a JSON object whose mappings field is a string of VLQ-encoded (variable-length quantity, base64) segments. Each segment records five numbers: the generated column, the index into the sources array, the original line, the original column, and optionally the names index. The numbers are delta-encoded relative to the previous segment, which is why the string looks like line noise but stays compact. source-map-explorer decodes this stream, and for every run of generated columns between one segment and the next, it charges those output bytes to the sources entry that segment names. Sum the runs per source file and you have the treemap: rectangle area equals total attributed bytes.

Two consequences fall out of this mechanism. First, attribution granularity is bounded by mapping density — the tool can only be as precise as the map. Minifiers emit one mapping per token, so per-file totals are accurate to the byte, but if a transform emitted a coarse map (one mapping per line, say), the numbers blur. Second, bytes with no covering segment are unattributable by construction. Bundler runtime glue, the module wrapper, and helper prelude often have no original source, so they land in the “unmapped” bucket. That bucket being small is normal; that bucket being large means your maps are low-fidelity and the whole report should be distrusted until you fix them.

Because the calculation is deterministic and offline, it is also cheap and repeatable. There is no bundler re-run, no instrumentation, no measurement variance — the same chunk and map always produce identical numbers. That determinism is exactly what makes the JSON report safe to assert on in CI, covered below.

Diagnosis workflow

  1. Run it against the emitted chunk. npx source-map-explorer 'dist/assets/*.js' opens an HTML treemap in the browser.
  2. Read largest-first. The biggest rectangles are the heaviest modules; a large node_modules/<pkg> box is a candidate for lazy-loading or replacement.
  3. Look for the same package twice. Two boxes with the same package name at different paths indicate a duplicate copy to deduplicate.

Work each of those three signals as a distinct investigation rather than a glance. The first — running against the emitted chunk — matters because you must analyze the production output, not the dev bundle. Vite’s dev server serves unbundled ES modules over native import, so there is no single chunk to attribute; only vite build produces the merged, minified file whose byte budget you actually ship. Always point the tool at dist/, never at anything the dev server produced.

The second signal, reading largest-first, is where most wins come from. A single dependency that owns 30–40% of a chunk is almost always doing more than the app needs: a charting library imported eagerly for a route most users never visit, a moment-style date library where a 6 KB alternative would do, an icon set imported as a whole barrel. The fix depends on which: move it behind a dynamic import() so it splits into its own lazily-fetched chunk, swap it for a lighter equivalent, or import the one submodule you use. Confirm the fix by rebuilding and re-running — the rectangle should shrink or vanish, and the chunk total should drop by the amount the treemap attributed to it. If the total does not move, you edited a code path that was already tree-shaken away and the real byte sink is elsewhere.

The third signal, the same package appearing twice, is a duplication bug and usually the highest-leverage thing on the page because it is pure waste — two copies of identical code. It happens when two dependencies pin incompatible version ranges of a shared package, so the package manager installs both and the bundler cannot merge them. Find the offenders with npm ls <package>, then resolve it by aligning the ranges, adding an overrides/resolutions entry to force a single version, or using dedupe in Vite’s resolve.dedupe for packages (like React) that must be a singleton. Confirm by re-running the analysis: the second box should be gone and the total down by exactly that copy’s size.

Read the treemap largest-first Scan the treemap from the largest rectangle down: a big node_modules box is a lazy-load or replacement candidate, and the same package name appearing in two boxes signals a duplicate copy to deduplicate. biggest boxlazy-load candidate same name twiceduplicate copy your app codeusually the smaller part two signals: one big box, or one name twice
Figure: the two things worth finding are a single large module and a package that appears more than once.

Annotated solution config

The goal here is to wire attribution into package.json so it runs the same way on your machine and in CI, with two entry points from one build. The analyze script opens the interactive treemap for a human exploring a regression; the analyze:json script emits the machine-readable report a check script consumes. Keeping both keeps the exploratory and enforced views in lockstep — they read the same chunk and the same map, so the number you eyeball is the number CI asserts on.

// package.json — a scriptable analyze step, Vite 5.4.x + source-map-explorer 2.5.3
{
  "scripts": {
    "build": "vite build --sourcemap",
    // HTML for humans:
    "analyze": "source-map-explorer 'dist/assets/*.js'",
    // JSON for CI assertions — one object of {sourceFile: bytes}:
    "analyze:json": "source-map-explorer 'dist/assets/index-*.js' --json > sme.json"
  }
}
// check-size.mjs — assert a single dependency stays under budget (Node 20+).
import { readFileSync } from 'node:fs';

const report = JSON.parse(readFileSync('sme.json', 'utf-8'));
const files = report.results?.[0]?.files ?? report.files ?? {};
const lodashBytes = Object.entries(files)
  .filter(([f]) => f.includes('node_modules/lodash'))
  .reduce((sum, [, bytes]) => sum + bytes, 0);

if (lodashBytes > 30_000) {
  console.error(`lodash contributes ${lodashBytes} bytes (> 30KB budget)`);
  process.exit(1);
}
console.log(`lodash: ${lodashBytes} bytes — OK`);

Two details in this script are deliberate. The report.results?.[0]?.files ?? report.files fallback exists because the JSON shape has shifted across source-map-explorer versions — 2.x nests per-file byte maps under results[0].files, while older output exposed a flat files object. Reading both keeps the check working across an upgrade instead of silently seeing zero bytes (which would pass the budget for the worst possible reason). The f.includes('node_modules/lodash') substring match sums every file the package contributed — its own submodules and any nested copies — rather than a single entry, so the number reflects the package’s full footprint in the chunk.

For anything beyond one dependency, assert against a small table of budgets so the script scales without copy-paste. This version groups attributed bytes by top-level package and fails on the first breach, which is what you want in CI — a clear “which package, how many bytes over” message rather than a single aggregate.

// check-budgets.mjs — group by package and enforce a budget table (Node 20+).
import { readFileSync } from 'node:fs';

const BUDGETS = { lodash: 30_000, dayjs: 12_000, react: 140_000 };

const report = JSON.parse(readFileSync('sme.json', 'utf-8'));
const files = report.results?.[0]?.files ?? report.files ?? {};

const totals = {};
for (const [file, bytes] of Object.entries(files)) {
  const m = file.match(/node_modules\/((?:@[^/]+\/)?[^/]+)/);
  if (!m) continue;                       // skip app code and the unmapped slice
  totals[m[1]] = (totals[m[1]] ?? 0) + bytes;
}

let failed = false;
for (const [pkg, limit] of Object.entries(BUDGETS)) {
  const used = totals[pkg] ?? 0;
  const ok = used <= limit;
  failed ||= !ok;
  console.log(`${ok ? 'OK ' : 'OVER'} ${pkg}: ${used} / ${limit} bytes`);
}
process.exit(failed ? 1 : 0);
HTML for humans, JSON for CI The same source-map-explorer run produces an interactive HTML treemap for a human to explore and a JSON report a script can parse to sum the bytes a specific dependency contributes and fail a build over a threshold. source-map-explorerone run HTML treemapexplore --json reportassert in CI
Figure: the --json flag is what turns exploration into a repeatable CI assertion.

Verification

# Vite 5.4.x — build, generate the JSON, run the assertion.
npm run build
npm run analyze:json
node check-size.mjs        # prints the byte total and exits non-zero if over

A correct run prints the exact byte contribution of the dependency and exits 0 when under budget, non-zero when over. Because the number comes from the map, it matches the raw size the treemap shows — the two views never disagree.

If the assertion fails, read the printed byte total against the treemap before touching code. A common false alarm is a threshold set from a gzip number while the tool reports raw bytes — the budget is simply in the wrong unit, not the code regressed. A genuine failure will show a rectangle in the HTML view that grew or appeared since the last green build; diffing the current sme.json against the committed baseline tells you which package moved and by how much. Only once you can name the package and the delta should you reach for a fix, because the wrong fix (splitting a package that was never the problem) leaves the total unchanged and wastes a review cycle.

CI integration

The report is deterministic, so it belongs in CI as a blocking gate, not a nightly nicety. The pattern is: build with maps, emit the JSON, run the check, and — because the maps were only needed for analysis — discard them before deploy so you never ship source maps by accident. A GitHub Actions job encodes exactly that sequence:

# .github/workflows/size.yml — block PRs that blow a dependency budget.
# Node 20, source-map-explorer 2.5.3
name: size
on: pull_request
jobs:
  budget:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: 20, cache: npm }
      - run: npm ci
      - run: npm run build            # vite build --sourcemap
      - run: npm run analyze:json     # writes sme.json
      - run: node check-budgets.mjs   # non-zero exit fails the job
      - run: rm -f dist/assets/*.map  # never deploy the maps

Two operational notes. Run the check on pull_request so a regression is caught before merge, when the diff is still fresh and the author is still in context; a check that only runs on main finds the problem after it is already everyone’s problem. And keep the budgets slightly above current usage — a few kilobytes of headroom — so ordinary churn does not turn the gate into noise that people learn to override. Tighten a budget deliberately after you land a reduction, so the win is locked in and cannot silently erode.

The JSON number matches the treemap area Because both the HTML treemap and the JSON report derive from the same source-map walk, the byte total the script asserts on is identical to the area the treemap shows, so the exploratory and automated views always agree. treemap areavisual bytes JSON totalasserted bytes = same source, two presentations
Figure: exploration and enforcement read the same map, so the numbers are guaranteed to match.

Gotchas & edge cases

Four source-map-explorer edge cases Attribution reports raw not gzip size so pair it with a gzip check; hashed filenames need a glob not a fixed name; an eval or inline source map may not be readable so emit external maps; and unmapped bytes show as an unattributed slice that is usually the runtime. reports raw, not gzip→ pair with a gzip check hashed filenames→ use a glob, not a name inline/eval map→ emit external maps unmapped slice→ usually the runtime
Figure: raw-not-gzip is the one people forget — a module that dominates raw may compress away.
  • Raw, not gzip. source-map-explorer reports parsed size; a module that dominates raw may compress well, so pair it with a gzip budget.
  • Hashed filenames. Vite emits index-<hash>.js; use a glob ('dist/assets/index-*.js') so the script keeps matching across builds.
  • Inline source maps. Some setups inline the map into the file or use eval; emit external .map files for reliable attribution.
  • Unmapped bytes. A small “unattributed” slice is normal — it is runtime/helper code with no single source origin.

Each of these bites in a specific way, so it is worth the longer version. The raw-versus-gzip gap is the one that produces wrong decisions most often: attribution counts parsed bytes, but the network ships gzip or brotli, and highly repetitive code (large lookup tables, generated enums, a JSON blob) can be 5–10× smaller compressed than parsed. The symptom is a module that looks alarming in the treemap but barely moves transfer size; the root cause is comparing a parsed number against a transfer budget; the fix is to keep two separate numbers — parsed from source-map-explorer for “what should I refactor” and gzip (from gzip-size or your CDN’s reported transfer) for “what does the user download” — and never cross the units. Confirm by running both on the same chunk once and noting the ratio for your codebase.

Hashed filenames are a content-addressing feature, not a nuisance: Vite hashes the chunk name from its contents so a changed chunk gets a new URL and old caches never serve stale code. The failure mode is a script that hard-codes index-a1b2c3d4.js; it works for exactly one build and then silently matches nothing, and a glob that matches nothing produces an empty report that passes every budget. The fix is the glob 'dist/assets/index-*.js'; confirm it is matching by asserting the report is non-empty in your check script rather than trusting a green exit code.

Inline and eval source maps break attribution because the tool expects an external .map sidecar it can read independently. Some framework starters or test-oriented configs default to sourcemap: 'inline' (base64-embedded in the chunk) or a devtool: eval-* equivalent, and the symptom is either an error or a report where everything collapses into one blob. The fix is to force build.sourcemap: true for the analysis build so Rollup writes a separate file; confirm by checking that a .map exists next to the chunk on disk before you run the tool.

The unmapped slice is expected, but its size is a health check on your maps. A slice under a few percent is bundler runtime with no original source and can be ignored. A slice that is 20% of the chunk means a transform in your pipeline (a Babel plugin, an older loader) emitted a coarse or broken map, so large ranges of output have no covering segment and the whole attribution is understating real modules. The fix is upstream — find the transform with the bad map and update or reconfigure it — and you confirm the fix by watching the unmapped slice shrink back to noise level on the next run.

When not to use this

Attribution is the right tool for “which module owns these bytes,” and the wrong tool for several adjacent questions. If you want to know why a module you thought was tree-shaken is still present, a size treemap only tells you it is there — reach for rollup-plugin-visualizer or Rollup’s own module graph, which show the import that keeps it alive. If you want to know what the user actually downloads over the wire, use a gzip/brotli transfer measurement, because parsed bytes are the wrong unit as noted above. And if you have no source maps and cannot produce them — an already-deployed third-party bundle, for instance — source-map-explorer has nothing to walk; a raw minified-size tool or a manual npm ls audit is the fallback. Use attribution when you own the build and can emit a map for the exact artifact you ship; outside that, its precision is unavailable and its numbers should not be trusted.

Comparison with rollup-plugin-visualizer

Both tools render a treemap, but they measure different things and run at different stages, which decides when to use each. source-map-explorer is a post-build, output-first tool: it reads the emitted chunk and its map, so its numbers are the real shipped bytes after minification and tree-shaking — the ground truth for a budget. rollup-plugin-visualizer is an in-build, graph-first plugin: it hooks Rollup and reports on modules as the bundler sees them, which lets it show gzip and brotli sizes and the import relationships, but its per-module figures reflect the bundler’s internal accounting rather than a byte-exact walk of the final file. In practice, use source-map-explorer when the question is “how many real bytes does this dependency cost in the artifact I deploy,” because it is decoupled from the build and works on any chunk that has a map; use the visualizer when the question is “how did this module get here and what does it compress to,” because it has the graph and the compressed sizes attribution lacks. They are complementary, and a mature size workflow runs both — the visualizer during investigation, source-map-explorer as the number CI enforces.