Fixing Vite Source Maps with Dynamic Imports

When Vite code-splits your application using dynamic import(), each lazy-loaded route or vendor chunk gets its own JavaScript file — and its own .map file — but if chunkFileNames and sourcemapFileNames patterns diverge, the maps are generated with names that observability platforms cannot match to their corresponding bundles. This page diagnoses that mismatch and shows how to fix it, building on Vite Build Settings for Accurate Stack Traces and the Source Map Generation & Stack Trace Debugging pipeline.

Vite dynamic import chunk and source map naming alignment Two side-by-side scenarios. On the left, aligned naming: App.js uses dynamic import() to produce LazyRoute-a1b2c3d4.js alongside LazyRoute-a1b2c3d4.js.map — Sentry matches them successfully. On the right, misaligned naming: the same chunk is named LazyRoute-a1b2c3d4.js but the map is named LazyRoute-a1b2c3d4.js-XXXXXX.map using a different hash seed — Sentry cannot find the map, resulting in a symbolication failure. Aligned ✓ Misaligned ✗ App.js entry chunk dynamic import() LazyRoute chunk split LazyRoute-a1b2c3d4.js LazyRoute-a1b2c3d4.js.map Sentry matches ✓ App.js entry chunk dynamic import() LazyRoute chunk split LazyRoute-a1b2c3d4.js LazyRoute-a1b2c3d4 .js-XXXXXX.map Sentry fails ✗ chunkFileNames and sourcemapFileNames must use identical [name]-[hash] tokens

Symptom / Trigger

After a production deployment, your observability platform reports symbolication failures only for errors originating in lazily loaded routes or vendor chunks — entry-point errors resolve correctly, but anything in a dynamic import() code-split chunk shows raw minified frames. In Sentry the event detail shows:

Could not load source map for https://cdn.example.com/assets/LazyRoute-a1b2c3d4.js:
  Request failed with status 404

TypeError: Cannot read properties of null
    at t (LazyRoute-a1b2c3d4.js:1:3847)       <- unresolved
    at HTMLButtonElement.<anonymous> (LazyRoute-a1b2c3d4.js:1:7201)  <- unresolved

Listing the dist/assets/ directory reveals a naming mismatch between the chunk JS and its companion map:

ls dist/assets/
# LazyRoute-a1b2c3d4.js
# LazyRoute-a1b2c3d4.js-Qq7rPmXs.map   ← different hash appended to map name
# Index-Ff3kLp9z.js
# Index-Ff3kLp9z.js.map                 ← entry chunk map is fine

The entry chunk (Index-Ff3kLp9z.js.map) uses the expected [name]-[hash].js.map pattern. The dynamic-import chunk’s map uses a different suffix because sourcemapFileNames was not explicitly configured and Rollup fell back to its internal default, which appends an additional content hash derived from the map’s own content rather than from the chunk name.

Root Cause Explanation

When you do not explicitly set sourcemapFileNames, Rollup computes the map file name from the chunk’s facadeModuleId and then appends a separate content hash of the map object itself. For entry chunks this usually coincides with chunkFileNames because the entry module ID is stable. For dynamic-import chunks the facadeModuleId is the lazy module path, and Rollup’s fallback hashing may produce a different suffix:

// vite.config.ts — broken config: chunkFileNames set, sourcemapFileNames omitted
import { defineConfig } from 'vite';

export default defineConfig({
  build: {
    sourcemap: 'hidden',
    rollupOptions: {
      output: {
        // chunk gets:  assets/LazyRoute-a1b2c3d4.js
        chunkFileNames: 'assets/[name]-[hash].js',
        // map gets:   assets/LazyRoute-a1b2c3d4.js-Qq7rPmXs.map  ← Rollup default
        // sourcemapFileNames not set — Rollup infers its own pattern
      },
    },
  },
});

The upload script then calls sentry-cli sourcemaps upload ./dist/assets/ which uploads LazyRoute-a1b2c3d4.js-Qq7rPmXs.map. Sentry’s symbolication engine looks for a map at the path derived from the chunk URL — ~/assets/LazyRoute-a1b2c3d4.js.map — finds no match, and falls back to raw frames.

Step-by-Step Fix

1. Align sourcemapFileNames with chunkFileNames

Set both chunkFileNames and sourcemapFileNames to use the identical [name]-[hash] interpolation sequence:

// vite.config.ts — aligned naming
import { defineConfig } from 'vite';

export default defineConfig(({ mode }) => ({
  build: {
    sourcemap: mode === 'production' ? 'hidden' : 'inline',
    rollupOptions: {
      output: {
        chunkFileNames: 'assets/[name]-[hash].js',        // → LazyRoute-a1b2c3d4.js
        entryFileNames: 'assets/[name]-[hash].js',
        assetFileNames: 'assets/[name]-[hash][extname]',
        sourcemapFileNames: 'assets/[name]-[hash].js.map', // → LazyRoute-a1b2c3d4.js.map
      },
    },
  },
}));

The [hash] token in sourcemapFileNames is resolved from the same chunk content hash used in chunkFileNames — not from the map’s own content — because Rollup computes both in the same generateBundle pass when sourcemapFileNames is explicitly provided.

2. Use manualChunks for deterministic names

By default, Vite names dynamic-import chunks after their module path, which can produce long or opaque identifiers. Using manualChunks gives each chunk a stable, human-readable name that will survive dependency version bumps:

// vite.config.ts — manualChunks for deterministic chunk naming
import { defineConfig } from 'vite';

export default defineConfig(({ mode }) => ({
  build: {
    sourcemap: mode === 'production' ? 'hidden' : 'inline',
    rollupOptions: {
      output: {
        chunkFileNames: 'assets/[name]-[hash].js',
        sourcemapFileNames: 'assets/[name]-[hash].js.map',
        manualChunks: {
          // group all react-* packages into a single vendor chunk
          vendor: ['react', 'react-dom', 'react-router-dom'],
          // each lazy route gets its own named chunk
          dashboard: ['./src/pages/Dashboard.tsx'],
          settings: ['./src/pages/Settings.tsx'],
        },
      },
    },
  },
}));

This ensures dashboard-[hash].js and dashboard-[hash].js.map are always named after the entry you specified, not after an internal module graph identifier that could change when you upgrade a dependency.

3. Fix the upload glob to cover all chunk maps

Many teams configure their upload script with a narrow glob that was written before code splitting was introduced:

# Wrong: only uploads maps directly in dist/assets/, not subdirectories
npx sentry-cli sourcemaps upload --release "${RELEASE}" ./dist/assets/*.map

Replace with a recursive glob that covers all generated maps regardless of nesting:

#!/usr/bin/env bash
# scripts/upload-maps.sh
set -euo pipefail

RELEASE="${npm_package_version:-$(git rev-parse --short HEAD)}"

npx sentry-cli sourcemaps upload \
  --release "${RELEASE}" \
  --url-prefix "~/assets" \
  ./dist/assets/   # pass directory, not glob; CLI recurses automatically

echo "Uploaded all source maps for release ${RELEASE}"

Passing the directory path rather than a glob lets sentry-cli discover all .map files recursively, including any future subdirectories introduced by Vite’s code splitting.

4. Validate all chunk maps before upload

After building, run a programmatic check that every .js chunk in dist/assets/ has a corresponding .map file with a resolvable mapping:

// scripts/validate-chunk-maps.mjs
import { SourceMapConsumer } from 'source-map';
import { readFile, readdir } from 'fs/promises';
import path from 'path';

const dir = path.resolve('dist/assets');
const files = await readdir(dir);
const jsFiles = files.filter(f => f.endsWith('.js'));
const mapFiles = new Set(files.filter(f => f.endsWith('.js.map')));

let failed = 0;
for (const js of jsFiles) {
  const expectedMap = js + '.map'; // e.g. LazyRoute-a1b2c3d4.js.map
  if (!mapFiles.has(expectedMap)) {
    console.error(`MISSING MAP: ${js} has no companion ${expectedMap}`);
    failed++;
    continue;
  }
  const raw = await readFile(path.join(dir, expectedMap), 'utf-8');
  const consumer = await new SourceMapConsumer(raw);
  const pos = consumer.originalPositionFor({ line: 1, column: 0 });
  if (!pos.source) {
    console.error(`BAD MAP: ${expectedMap} — column 0 does not resolve`);
    failed++;
  } else {
    console.log(`OK: ${expectedMap}${pos.source}:${pos.line}`);
  }
  consumer.destroy();
}

if (failed > 0) {
  console.error(`\n${failed} map file(s) failed validation.`);
  process.exit(1);
}

Add this to the build pipeline before upload:

{
  "scripts": {
    "build": "vite build && node scripts/validate-chunk-maps.mjs",
    "postbuild": "bash scripts/upload-maps.sh && find ./dist -name '*.map' -delete"
  }
}

Verification

Confirm that after npm run build, the count of .js files matches the count of .map files in dist/assets/:

# Count should match — one .map per .js
echo "JS files:  $(find dist/assets -name '*.js'  | wc -l)"
echo "Map files: $(find dist/assets -name '*.map' | wc -l)"

# Example correct output:
# JS files:  6
# Map files: 6

List them side-by-side to confirm the naming pattern is consistent:

ls dist/assets/ | sort
# assets/Index-Ff3kLp9z.js
# assets/Index-Ff3kLp9z.js.map
# assets/LazyRoute-a1b2c3d4.js
# assets/LazyRoute-a1b2c3d4.js.map
# assets/vendor-9pQkRmLz.js
# assets/vendor-9pQkRmLz.js.map

In your Sentry project, navigate to a production error originating in a lazy route and verify the stack frame resolves to original source:

npx sentry-cli sourcemaps explain \
  --release "${npm_package_version}" \
  "~/assets/LazyRoute-a1b2c3d4.js" 1 3847
# Expected:
#   Resolved to src/pages/Dashboard.tsx line 42, column 12

Edge Cases & Gotchas

  • inlineDynamicImports: true disables chunking — Setting this Rollup option merges all dynamic imports into a single bundle. There will be no separate chunk .map files, but the single bundle’s map grows very large. This is appropriate only for environments that cannot serve multiple HTTP requests (e.g., certain SSR edge runtimes). It defeats code-splitting performance benefits.
  • React.lazy + Suspense with nested routes — Each React.lazy(() => import('./Page')) call creates one chunk. If you have 40 lazy routes, you get 40 chunk maps. Ensure your upload script’s timeout is sufficient for the total upload size, and consider increasing SENTRY_CLI_TIMEOUT in CI environments.
  • Vendor chunk containing multiple modules from manualChunks — When manualChunks.vendor groups 20+ packages, the resulting chunk and map are large. The chunk hash is stable between builds only if none of the vendor packages change. A minor version bump to React will generate a new hash, invalidating the old Sentry map. Always re-upload maps on every build, not just when source files change.
  • rollupOptions.output as array — If you configure output as an array for multiple formats, each element needs its own sourcemapFileNames. Rollup processes each output independently and will fall back to its default naming on any descriptor that omits the key.

FAQ

Why do only lazy-loaded routes have broken maps, while the entry chunk works? Entry chunks are processed first by Rollup and use the module’s facadeModuleId (usually the file name) as the chunk name. Dynamic-import chunks may get a synthetic name derived from the import call site, and Rollup’s fallback sourcemapFileNames logic produces a different hash suffix for those. Setting sourcemapFileNames explicitly tells Rollup to use the same pattern for all chunks regardless of how they were created.

Can I use rollupOptions.output as an array for ESM and CJS builds with separate map strategies? Yes. Each element of the output array is an independent Rollup output, so you can set sourcemap: 'hidden' in the ESM output and sourcemap: false in a legacy IIFE output:

// vite.config.ts — array output with per-format sourcemap control
import { defineConfig } from 'vite';

export default defineConfig({
  build: {
    rollupOptions: {
      output: [
        {
          format: 'es',
          chunkFileNames: 'assets/[name]-[hash].js',
          sourcemapFileNames: 'assets/[name]-[hash].js.map',
          sourcemap: 'hidden', // modern browsers: upload maps
        },
        {
          format: 'iife',
          chunkFileNames: 'legacy/[name]-[hash].js',
          sourcemap: false, // legacy build: no maps
        },
      ],
    },
  },
});

Does this fix apply to Rollup projects that don’t use Vite? Yes. The sourcemapFileNames and chunkFileNames alignment requirement is a Rollup behaviour that Vite inherits. Any Rollup configuration — whether used directly or through another framework — has the same issue if sourcemapFileNames is omitted. The fix is identical: explicitly set sourcemapFileNames to mirror the [name]-[hash] pattern used in chunkFileNames.