Analyzing Chunks with source-map-explorer

dist/assets/
  index-4h2Kx9.js         31 KB
  dashboard-9pQr3f.js    304 KB   ← this one
  vendor-6mNb2a.js       118 KB

You split a route into its own chunk expecting something in the range of 80–120 KB. Instead dashboard-9pQr3f.js comes out at 304 KB, and the build output gives you nothing more than that one number. webpack-bundle-analyzer or rollup-plugin-visualizer might already be running in CI and would have flagged the total, but neither tells you which of the dozen or so imports inside the dashboard route is responsible. This is exactly the scenario source-map-explorer exists for: point it at the one oversized file and its map, and it reconstructs a byte-level breakdown of everything the minifier folded together.

Root cause: one number, a dozen possible culprits

A route-level chunk like dashboard-9pQr3f.js typically pulls in application code, a handful of shared utilities, and at least one or two heavier third-party dependencies (a charting library, a rich-text editor, a data grid). Any of the following can independently account for the extra 150–200 KB above expectation:

  • A dependency that was supposed to be lazy-loaded inside the dashboard but is actually imported eagerly at the top of the module, defeating the split point.
  • A duplicate copy of a package already present in the main vendor chunk, resolved separately because of a version mismatch — the exact failure mode covered in the sibling guide finding duplicate dependencies in a bundle.
  • Dead code that tree-shaking should have removed but didn’t, because the dependency ships CommonJS or an imprecise sideEffects declaration.
  • A barrel import pulling in far more of a utility library than the dashboard route actually uses.

Chunk totals cannot distinguish between these. This page assumes you already understand why a map is required for byte-level attribution — covered in the parent cluster, attributing bundle bloat with source maps — and focuses purely on the CLI mechanics of running source-map-explorer against this one chunk and reading what comes back.

If this is the first time you’ve generated a map specifically to run this kind of analysis, the parent cluster’s generation section and the dedicated source map generation and debugging workflows guide both cover the devtool and build.sourcemap options in more depth — including the difference between a map good enough for byte attribution and one good enough for step-through debugging. This page picks up from the point where a .js and .js.map pair already exists for the chunk you want to investigate.

Exact commands to run

Step 1 — confirm the map exists next to the chunk

# Confirm the .map file is present and matches the chunk hash
ls -la dist/assets/dashboard-9pQr3f.js dist/assets/dashboard-9pQr3f.js.map

If the .map file is missing, no attribution is possible yet — rebuild with devtool: 'hidden-source-map' (Webpack 5) or build.sourcemap: true (Vite 5+) first, as covered in the parent cluster’s generation section, then re-run the build that produced this chunk.

Step 2 — target that one chunk explicitly

# Run source-map-explorer against the exact oversized chunk
npx source-map-explorer dist/assets/dashboard-9pQr3f.js

source-map-explorer auto-discovers the sibling .js.map file when the filenames match. If your build routes maps to a different directory (a common pattern for separating public JS from restricted map access), pass the map path explicitly as a second argument:

# Explicit map path when JS and .map are not siblings
npx source-map-explorer dist/assets/dashboard-9pQr3f.js dist/maps/dashboard-9pQr3f.js.map

This opens an interactive HTML treemap directly in your default browser. Nothing is written to disk unless you also pass --html (covered below).

Step 3 — read the treemap by area, not by list order

The treemap renders every attributed original file as a box; box area is proportional to bytes, not alphabetical order or the order modules were imported. Hovering a box shows the exact file path and byte count. For a 304 KB chunk, a realistic breakdown might look like:

dashboard-9pQr3f.js  (304,128 bytes)
├─ node_modules/heavy-chart-lib/dist/index.js         118,784 bytes  (39%)
├─ node_modules/heavy-chart-lib/dist/index.js          61,440 bytes  (20%)   ← second copy, different resolved path
├─ src/routes/dashboard/DashboardView.tsx               24,576 bytes  (8%)
├─ node_modules/lodash/lodash.js                        22,528 bytes  (7%)
├─ src/routes/dashboard/widgets/                        41,984 bytes  (14%)
└─ [unmapped]                                            9,216 bytes  (3%)

Two things jump out immediately in output shaped like this: heavy-chart-lib appears twice at different byte counts — a duplicate dependency worth investigating with the sibling guide linked above — and a bare lodash import (rather than lodash-es or per-function imports) is pulling in the full library rather than only the functions the dashboard actually calls. Both are concrete, fixable findings that a flat 304 KB total could never have surfaced. The broader technique for correcting the lodash case specifically lives in advanced tree-shaking and dependency optimization, which covers utility-library import patterns that keep tree-shaking effective.

Step 4 — export --json for CI

Interactive HTML is fine for a one-off investigation but useless in a pipeline. --json prints the same attribution data as structured output to stdout:

# Emit JSON attribution for the dashboard chunk to a file
npx source-map-explorer dist/assets/dashboard-9pQr3f.js --json > dashboard-attribution.json
// dashboard-attribution.json — shape returned for a single-bundle run
{
  "results": [
    {
      "bundleName": "dist/assets/dashboard-9pQr3f.js",
      "totalBytes": 304128,
      "unmappedBytes": 9216,
      "files": {
        "node_modules/[email protected]/dist/index.js": 118784,
        "node_modules/[email protected]/dist/index.js": 61440,
        "src/routes/dashboard/DashboardView.tsx": 24576,
        "node_modules/lodash/lodash.js": 22528
      }
    }
  ]
}

A minimal CI gate can parse this and fail the build if a named module crosses a threshold, or if the same package name appears under two different version-tagged paths:

# .github/workflows/chunk-attribution.yml — fail if a single module exceeds 100 KB
- name: Attribute dashboard chunk and enforce per-module budget
  run: |
    npx source-map-explorer dist/assets/dashboard-*.js --json > attribution.json
    node -e "
      const data = require('./attribution.json').results[0].files;
      for (const [file, bytes] of Object.entries(data)) {
        if (bytes > 102400) {
          console.error(file + ' exceeds 100 KB: ' + Math.round(bytes / 1024) + ' KB');
          process.exit(1);
        }
      }
    "

Step 5 — generate a shareable --html report

When a finding needs to go into a pull request description or a ticket, --html writes a standalone file that renders the same treemap without requiring the reviewer to run the CLI themselves:

# Write a standalone report file for sharing in a PR or ticket
npx source-map-explorer dist/assets/dashboard-9pQr3f.js --html dashboard-report.html

The generated file embeds the treemap rendering and data inline, so it opens correctly from a downloaded artifact or a CI-uploaded build artifact without any server.

A note on Vite chunk naming

Vite’s default chunkFileNames pattern produces hashes that change whenever any module inside the chunk changes, which is exactly what makes commands like Step 1 above brittle if copy-pasted between builds — the filename in your terminal history will not match the filename in a fresh build. Two habits avoid repeatedly hunting for the current hash: pin a stable, unhashed name for the chunk under investigation temporarily (chunkFileNames: 'assets/dashboard-debug.js' in build.rollupOptions.output) for the duration of the investigation, or script the lookup instead of hardcoding it:

# Resolve the current dashboard chunk filename dynamically instead of hardcoding a hash
CHUNK=$(ls dist/assets/dashboard-*.js | grep -v '\.map$' | head -1)
npx source-map-explorer "$CHUNK"

This also matters when the chunk in question sits behind a lazy route boundary managed by Rollup’s chunking heuristics, which is the same underlying mechanism documented in Vite’s module graph and dependency resolution — understanding how Vite assigns modules to chunks in the first place makes it much faster to predict which chunk a given import will end up attributed to before you even run the tool.

Step-by-Step Verification

  1. Confirm the chunk filename in the command matches the current build. Chunk hashes change on every content change — a stale hash in your command silently analyzes an old artifact if it’s still present in dist/.
  2. Check unmappedBytes against totalBytes. Anything above roughly 5–8% unmapped suggests a loader in the chain isn’t forwarding source maps; investigate before trusting the rest of the attribution.
  3. Cross-reference the top attributed file against your import statements. Open the actual route file and confirm the top few attributed modules correspond to imports you recognize and intend.
  4. Re-run after any fix. If you added a dedupe rule or converted an eager import to import(), rebuild and re-run the same command — the specific byte count that was flagged should drop by roughly the amount you expected.
  5. Commit the CI gate once the investigation is resolved. A per-chunk, per-module budget check like the one in Step 4 above prevents the same regression from landing silently again.

Edge Cases and Gotchas

Gotcha 1 — running against a chunk with an inline (base64) source map

If a build was configured with an inline map (devtool: 'inline-source-map' in Webpack, or sourcemap: 'inline' in Vite), there is no separate .js.map file — the map is embedded as a data: URI comment inside the JS file itself. source-map-explorer handles this transparently when pointed at just the .js file, but inline maps inflate the file you’re analyzing by 2–4× relative to what a real user downloads. Always regenerate with a separate (non-inline) map before attributing a chunk meant to represent production byte counts.

Gotcha 2 — glob patterns silently matching more than intended

# Careful: this glob also matches dashboard-legacy-*.js if that chunk exists
npx source-map-explorer "dist/assets/dashboard-*.js"

A broad glob combines multiple chunks into one treemap, which is useful for a directory-wide overview but will mislead a targeted investigation — a large box might belong to a sibling chunk, not the one you meant to isolate. When investigating one specific regression, always use the exact filename with its hash, not a wildcard.

Gotcha 3 — --only-mapped hides the exact signal you need

The --only-mapped flag excludes the [unmapped] bucket from the treemap entirely, which can make a report look cleaner for a stakeholder audience but removes the one metric that tells you whether the map itself is trustworthy. Keep [unmapped] visible during an investigation; only strip it for a final polished report after you’ve already confirmed the map fidelity is good.

FAQ

What do I do if source-map-explorer can’t find the source map?

By default it looks for a //# sourceMappingURL comment in the JS file or a same-named .map file next to it. If neither exists, pass the map path explicitly as a second argument: source-map-explorer dist/chunk.js dist/chunk.js.map. If the chunk was built without sourcemap generation enabled at all, you must rebuild with sourcemaps turned on before analysis is possible.

Can I run source-map-explorer against more than one chunk at once?

Yes. Passing a glob such as dist/assets/*.js produces a single combined treemap grouped by chunk, which is useful for an overview, but for isolating why one specific chunk is oversized, target that chunk’s exact filename so the byte totals are not diluted by unrelated chunks.

Why does the --json output not exactly match the --html report’s totals?

Both read the same underlying mapping data, so total bytes should match. A mismatch usually means the two commands ran against different build outputs — for example the --html report was generated before a rebuild and the --json export ran after. Always regenerate both from the same build to compare them.