Source Maps and Production Debugging

A source map is a JSON artifact that maps each position in a minified, transpiled bundle back to the original line, column, and file it came from, letting a debugger or error tracker reconstruct readable stack traces from production output. Without one, a runtime exception in index-a3f9c2.js surfaces as t is not a function at column 81204, with no path back to the .ts file that actually threw. This guide covers how Vite, Rollup, esbuild, and Turbopack emit those maps, the inline/external/hidden trade-offs, the sourcesContent field, the security exposure of leaking maps to the public, and the workflow for deobfuscating minified stacks against an error tracker. For the underlying compilation model these maps annotate, see Core Concepts of Modern Bundling before tuning emit modes, and treat source map generation as a first-class build output rather than a debugging afterthought.

Source map generation and resolution pipeline Original source is bundled and minified into output plus a .map file, which an error tracker uses to resolve a minified stack back to original positions. src/app.ts original source index-a3f9.js minified output index-a3f9.js.map VLQ mappings Error tracker resolved stack app.ts:42:7 bundle + minify Map .json fields version: 3 sources: ["src/app.ts"] sourcesContent: ["import ..."] (optional, embeds code) mappings: "AAAA,IAAM,QAAQ;..." (base64 VLQ) //# sourceMappingURL=index-a3f9.js.map pragma appended to the bundle tail hidden mode omits this pragma
Figure: how a bundler emits an output file plus its .map, and how an error tracker resolves a minified stack back to original positions.

Prerequisites

Source map fidelity depends on every stage of the pipeline preserving mappings, so the tool versions matter:

  • Vite 5.x or 6.x (Rollup 4.x under the hood for production builds; esbuild for dev-time transforms).
  • Rollup 4.x standalone, if you bundle libraries directly.
  • esbuild 0.19–0.25, used directly or via Vite’s dependency pre-bundling.
  • Turbopack via Next.js 14 or 15 for the App Router production builds.
  • Node.js 18, 20, or 22 — the resolver and --enable-source-maps flag behave consistently across all three for server-side stacks.

You should also have an error tracker (Sentry, Datadog RUM, Bugsnag, or Rollbar) provisioned with a release/version concept, because hidden maps are useless unless something uploads and stores them out-of-band. For exact version pairings and known conflicts, consult the Bundler Version Compatibility Reference.

Core Mechanics: VLQ Mappings and the sourceMappingURL Pragma

A Source Map v3 file is JSON with five load-bearing fields: version (always 3), sources (original file paths), sourcesContent (the literal text of each source, optional), names (identifier table), and mappings (the payload). The mappings string is the part that actually does the work, and it is the reason maps are compact: it is a semicolon- and comma-delimited sequence of Base64 VLQ (Variable-Length Quantity) segments.

Each line of generated output is one semicolon-separated group. Within a group, each comma-separated segment is up to five VLQ-encoded integers, all stored as deltas relative to the previous segment: generated column, source-file index, original line, original column, and (optionally) the names index. Because the values are relative, a 200 KB minified bundle maps to a few tens of KB of mappings, but it also means a single corrupted delta cascades — every position after it resolves wrong. This is why a map that is “mostly right but off by a few lines” almost always indicates a transform in the chain that rewrote code without composing its own map into the chain.

The browser or Node runtime locates the map through the //# sourceMappingURL=... pragma — a comment appended to the tail of the generated file. It can point to an external relative path (//# sourceMappingURL=index-a3f9.js.map) or carry an inline data:application/json;base64,... URI. Hidden mode is the important variant: the bundler still writes the .map file but omits the pragma entirely, so browsers never request it and casual visitors cannot discover it, while your error tracker — which was handed the map at deploy time — can still symbolicate. Chaining multiplies risk: when Babel, then SWC, then Rollup each touch the code, every stage must consume the prior map and emit a composed one, or the final mapping points at intermediate, already-transformed code.

Configuration & CLI Reference

Vite (build.sourcemap)

Vite exposes a single build.sourcemap option that is passed through to Rollup. It accepts true (external .map + pragma), 'inline' (data URI embedded in the bundle), 'hidden' (external .map, no pragma), and false.

// vite.config.ts — Vite 5.x / 6.x (Rollup 4.x)
import { defineConfig } from 'vite';

export default defineConfig({
  build: {
    // 'hidden' is the correct production choice: maps are emitted to disk
    // for upload to an error tracker, but no sourceMappingURL pragma is
    // written, so browsers never fetch them and they are not publicly linked.
    sourcemap: 'hidden',
    rollupOptions: {
      output: {
        // Drop the original source text from sources[] to avoid shipping
        // proprietary code inside .map files. Disable only if your tracker
        // needs embedded sources and the maps stay private.
        sourcemapExcludeSources: true,
      },
    },
  },
});

Rollup (output.sourcemap)

Standalone Rollup mirrors the same vocabulary on the output object.

// rollup.config.js — Rollup 4.x, Node 20+
export default {
  input: 'src/index.ts',
  output: {
    dir: 'dist',
    format: 'es',
    sourcemap: 'hidden',          // true | 'inline' | 'hidden' | false
    sourcemapExcludeSources: true, // omit sourcesContent from the .map
    // Optional: rewrite the recorded source paths so they match what the
    // tracker expects (e.g. strip an absolute monorepo prefix).
    sourcemapPathTransform: (rel) => rel.replace(/^\.\.\//, ''),
  },
};

esbuild (sourcemap, sourcesContent)

esbuild splits the concern into two flags. sourcemap controls emission mode; sources-content is a separate boolean that controls whether the original text is embedded.

# esbuild 0.25.x, Node 20+ — CLI
esbuild src/index.ts --bundle --minify \
  --sourcemap=external \      # linked | inline | external | both
  --sources-content=false \   # strip original code from the .map
  --outfile=dist/bundle.js
// build.mjs — esbuild 0.25.x JS API
import { build } from 'esbuild';

await build({
  entryPoints: ['src/index.ts'],
  bundle: true,
  minify: true,
  // 'external' emits bundle.js.map and appends a sourceMappingURL pragma.
  // 'linked' is the alias; 'external' here means "no pragma" only in older
  // docs — in 0.19+ use the four documented modes below explicitly.
  sourcemap: 'external',
  sourcesContent: false, // equivalent to --sources-content=false
  outfile: 'dist/bundle.js',
});

esbuild’s modes are linked (external file + pragma), inline (data URI), external (file written, no pragma — esbuild’s equivalent of hidden), and both (inline + external). Choose external when you want the hidden-map behavior.

Turbopack / Next.js

Turbopack generates source maps automatically for both server and client in next build. There is no per-mode toggle equivalent to build.sourcemap; instead, control whether browser source maps ship publicly via productionBrowserSourceMaps, and rely on the @sentry/nextjs (or vendor) wrapper to capture and upload the maps Turbopack writes for the server runtime.

// next.config.js — Next 15.x with Turbopack, Node 20+
/** @type {import('next').NextConfig} */
module.exports = {
  // false (default) keeps client .map files out of the public bundle.
  // Server source maps are always written for stack symbolication.
  productionBrowserSourceMaps: false,
};

Numbered Workflow: Ship Hidden Maps Without Leaking Them

  1. Set the emit mode to hidden/external. In Vite/Rollup use sourcemap: 'hidden'; in esbuild use --sourcemap=external. Confirm the build wrote *.js.map files and that the bundles contain no sourceMappingURL pragma: grep -rl "sourceMappingURL" dist/assets/*.js should print nothing.
  2. Decide on sourcesContent. For private application code, set sourcemapExcludeSources: true (Vite/Rollup) or --sources-content=false (esbuild) so the .map carries mappings but not your original code. Keep it enabled only if the maps stay private and your tracker needs embedded sources.
  3. Tag the release. Compute a stable release identifier (git SHA or version) and inject it into the runtime so emitted errors carry the same release the maps are filed under.
  4. Upload maps to the tracker as a separate, authenticated step keyed by that release. The maps go to the tracker’s storage, never to the CDN origin.
  5. Delete maps from the deploy artifact after upload so they never reach the public bucket: find dist -name '*.map' -delete.
  6. Verify symbolication by throwing a deliberate error in production and confirming the tracker resolves the frame to an original src/... path and line. The end-to-end mechanics for one tracker are detailed in Uploading source maps to Sentry from a Vite build.

Debugging & Failure Modes

Wrong source paths in the resolved stack

The tracker resolves a frame to ../../src/app.ts or an absolute /Users/you/proj/src/app.ts that does not match the path it indexed under the release. The cause is the sources[] array recording paths relative to the bundler’s working directory rather than the project root. Fix with sourcemapPathTransform (Rollup) or by running the upload from the repository root with a --url-prefix/--strip-prefix that aligns the recorded paths to what the tracker stores.

Missing sourcesContent

The tracker symbolicates to the right file and line but shows no code context, only // no source available. This means sourcesContent was stripped (or never emitted) and the tracker cannot fetch the original from your repo. Either keep sourcesContent (accepting that the private maps embed code) or configure the tracker with read access to the source at that commit. Do not “fix” this by shipping public maps.

Leaked maps in production

curl -s https://app.example.com/assets/index-a3f9.js | tail -1 shows a //# sourceMappingURL pragma, or index-a3f9.js.map is fetchable over HTTP. Anyone can now reconstruct your unminified, commented source. The root cause is sourcemap: true (not 'hidden') or a deploy step that copied .map files to the public bucket. Switch to hidden mode and add the find dist -name '*.map' -delete step after upload. Treat a leaked map as a source-disclosure incident, not a cosmetic bug.

Off-by-N mappings from an un-composed transform

Lines resolve but are consistently shifted. A transform in the chain (a custom Babel plugin, a string-replace step, a banner injection) rewrote code without emitting or composing its own map. Audit every plugin between source and output and ensure each returns { code, map }; a plugin that returns only code silently invalidates downstream mappings.

Performance Impact

Generating source maps adds build time roughly proportional to output size — typically a 10–25% wall-clock increase on a Vite production build, concentrated in Rollup’s render phase where mappings are serialized. Disk and upload cost scale with sourcesContent: embedding original code can double or triple the .map size, which matters for upload bandwidth in CI but never for end users (hidden maps are never served). There is zero runtime cost to shipped users when maps are external or hidden, because the browser only fetches a .map when DevTools is open and the pragma is present. Inline maps are the exception — they bloat the served bundle by the full map size and should never be used in production. Measure the build delta directly: time vite build with and without build.sourcemap, and compare du -sh dist.

Compatibility Matrix

Tool Option Modes sourcesContent control Hidden equivalent
Vite 5.x / 6.x build.sourcemap true, 'inline', 'hidden', false rollupOptions.output.sourcemapExcludeSources 'hidden'
Rollup 4.x output.sourcemap true, 'inline', 'hidden', false output.sourcemapExcludeSources 'hidden'
esbuild 0.19–0.25 sourcemap linked, inline, external, both sourcesContent (boolean) external
Turbopack (Next 14/15) productionBrowserSourceMaps + plugin server: always; client: opt-in via uploader plugin default (client off)

In-Depth Guides