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.
The problem exists because the code that runs in production bears almost no textual resemblance to the code you wrote. A modern build takes TypeScript or JSX, strips types, downlevels syntax for older targets, tree-shakes dead exports, concatenates hundreds of modules into a handful of chunks, renames every local binding to a single letter, and collapses the whole thing onto as few lines as the minifier can manage. Each of those transforms is individually reasonable and collectively destroys every human-readable coordinate an error report depends on. A stack frame like at t (index-a3f9c2.js:1:81204) is not a bug in your error tracker; it is the honest truth about where the exception occurred in the artifact the browser actually executed. The source map is the only thing that can invert that pipeline, and it can only do so if every stage along the way faithfully recorded what it did.
What breaks without a correct map is not the application — it still runs — but your ability to operate it. A production incident with unsymbolicated stacks means triage happens by guesswork: you correlate the minified column against a local build, bisect by feature flag, or wait for a customer to describe reproduction steps. Mean time to resolution stretches from minutes to hours, and intermittent errors that only fire on a subset of browsers may never be diagnosed at all. In the build pipeline, source maps sit at the very end, downstream of transpilation, bundling, and minification, which is precisely why they are fragile: they are the composed record of every earlier transform, and a single stage that rewrites code without updating the map poisons everything after it. Getting emit modes, path recording, and upload discipline right is therefore not a nicety — it is the difference between a debuggable production system and an opaque one.
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-mapsflag 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.
The version constraints are not arbitrary caution. Source map generation changed materially across recent releases of every tool listed. esbuild reworked its sourcemap mode vocabulary and its handling of sources paths several times in the 0.1x series, so a config that produced correct linked maps under 0.14 can silently emit pragma-less maps under 0.19 if you never revisited the flag. Vite’s move from Rollup 3 to Rollup 4 changed how sourcemapExcludeSources interacts with the pre-bundling step, and Turbopack only reached production-grade map fidelity for next build in the Next 14/15 window — earlier canary builds emitted maps that resolved server frames but not client ones. Pinning the toolchain to a known-good set of versions is the cheapest way to keep the map contract stable across deploys, and it is the first thing to check when symbolication that worked last quarter stops working after a routine dependency bump. Treat a source-map regression the same way you would treat a broken test: bisect the dependency change that introduced it rather than working around the symptom in the tracker.
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 delta encoding is worth understanding concretely, because it dictates how the failure modes present. Base64 VLQ packs a signed integer into one or more Base64 digits: the low bit of the first digit is the sign, the next bits are magnitude, and a continuation bit signals whether another digit follows. A segment therefore never stores an absolute position; it stores “how far from the previous one.” The first field, generated column, resets to an absolute value at the start of each line (each ;), but the source index, original line, and original column deltas accumulate across the entire file. That asymmetry is the root cause of the most common corruption signature: when a transform drops or adds a segment without adjusting the running totals, the generated-column axis stays roughly aligned — because it re-anchors every line — while the source line and column drift by a fixed offset that grows with distance into the file. An error that resolves to the right file but the wrong line, consistently off by the same amount, is this bug. An error that resolves to a completely unrelated file is usually a sources[] index that has slipped, which is a more severe form of the same corruption.
The names field deserves a note because it is where symbolication gets its human-readable identifiers. When a minifier renames handleSubmit to t, the original name is stored once in names[] and each relevant segment carries a delta index into that array. A map with an empty or truncated names table still resolves file and line correctly but shows mangled identifiers in the resolved frame, which is a milder degradation than wrong lines and is usually caused by a minifier configured to drop name mappings for size. It is rarely worth chasing unless your tracker groups errors by function name.
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.
Map composition is the mechanism that makes a multi-stage pipeline debuggable, and it is worth stating precisely because it is where most subtle bugs live. Each transform receives some input text and, ideally, an input map describing where that text came from. It produces output text and an output map from output positions to input positions. Composition is the mathematical join of those two maps: for every output position, look up its input position in the new map, then look up that input position in the incoming map, yielding a mapping from output all the way back to the true original. Bundlers and libraries like @ampproject/remapping perform this join automatically when each stage returns a well-formed map. The failure is asymmetric and quiet: if any stage returns transformed code but no map, composition has nothing to join through, so it treats that stage’s input as the original and every downstream position resolves to intermediate, post-transform code rather than your source. Nothing errors; the map is simply wrong by exactly the amount that stage moved the code.
How the runtime resolves a frame
When an exception is thrown, the JavaScript engine records the generated position — file URL, line, and column of the throwing instruction in the shipped bundle. Symbolication is the reverse lookup. The resolver loads the map, decodes the mappings string into a searchable list of segments sorted by generated line and column, then binary-searches for the segment whose generated position is the greatest one not exceeding the frame’s position. That segment yields the source index, original line, and original column. In the browser, DevTools does this live whenever the pragma is present and the map is fetchable; in Node, the --enable-source-maps flag makes the runtime perform the same lookup so that Error.stack is printed against original positions. An error tracker does it server-side, using the map you uploaded and keyed by release, which is why the release identifier on the incoming event must match the release the map was filed under exactly — a mismatch means the resolver either finds no map or, worse, applies a map from a different build and produces confidently wrong lines.
Configuration & CLI Reference
hidden/external-no-pragma is the production pick.Every tool below exposes the same four conceptual modes — inline, external-with-pragma, external-without-pragma, and off — under different names, and the single decision that matters for a public production deployment is choosing the external-without-pragma variant so that maps exist on disk for upload but are never advertised to browsers. The rest of the configuration surface is about which original text to embed and how the recorded paths are spelled. Read each tool’s section for its exact spelling; the Compatibility Matrix at the end reduces the whole thing to one row per tool.
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. Because the option is a straight pass-through, everything Rollup does with maps is reachable from a Vite build through rollupOptions.output, including sourcemapExcludeSources and sourcemapPathTransform. The one wrinkle specific to Vite is that dev-server transforms run through esbuild while production builds run through Rollup, so a map that looks correct under vite dev tells you nothing about the production map — always validate against the output of vite build, never the dev server.
// 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. The sourcemapPathTransform hook is the escape valve you reach for most often when publishing a library or building from inside a monorepo: Rollup records sources[] relative to the output directory by default, which frequently produces paths with ../../ prefixes or absolute machine paths that no tracker or consumer can resolve. The hook runs once per recorded source and lets you normalize each path to a stable, repository-relative form before it is written into the map. Keep the transform pure and deterministic — it runs on every build, and any nondeterminism (for example, embedding an absolute working directory) makes maps differ between CI runs and breaks release-keyed upload deduplication.
// 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. This is the single most confused flag in the whole topic, because esbuild’s external is the exact opposite of what “external” means in Vite and Rollup: in Vite/Rollup, true writes an external file with a pragma, whereas esbuild’s external writes a file without one. If you migrate a config from Vite to a direct esbuild invocation and copy the mode name literally, you will accidentally flip whether the pragma ships. When in doubt, do not trust the flag name — grep the built bundle for sourceMappingURL and let the presence or absence of that string be the source of truth.
The both mode has one legitimate use: local debugging where you want DevTools to pick up the inline map immediately while also producing a standalone .map file for a tool that reads it separately. Never use both in production, because the inline copy defeats the entire point of hidden maps by shipping the full map inside the served JavaScript. esbuild also differs from Rollup in that it has no path-transform hook; if you need to rewrite sources[] paths, do it as a post-processing pass over the emitted .map JSON, or rely on your uploader’s --strip-prefix-style option at upload time.
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. The mental model that trips people up is expecting a single knob: Next splits the concern along the server/client boundary. Server maps are always written to .next/server because the Node runtime needs them to symbolicate server-component and route-handler stacks, and they never leave your infrastructure, so there is no public-exposure decision to make about them. Client maps are the ones a browser could fetch, and productionBrowserSourceMaps: false (the default) keeps them out of the public .next/static output entirely. The correct production posture is therefore to leave that flag false and let the Sentry (or Datadog/Bugsnag) build plugin read the maps out of the .next directory during the build and upload them keyed by release, exactly as you would run a manual upload step for a Vite build. Setting productionBrowserSourceMaps: true is the Next equivalent of sourcemap: true in Vite — it ships fully public maps and should be reserved for internal or staging environments.
// 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
- Set the emit mode to hidden/external. In Vite/Rollup use
sourcemap: 'hidden'; in esbuild use--sourcemap=external. Confirm the build wrote*.js.mapfiles and that the bundles contain nosourceMappingURLpragma:grep -rl "sourceMappingURL" dist/assets/*.jsshould print nothing. This grep is the load-bearing check of the whole workflow — it is the one assertion that does not depend on trusting a flag name, and it is cheap enough to run as a hard gate in CI. If it prints any file, the build will publish a pointer to your maps and the remaining steps cannot save you; stop and fix the emit mode before deploying. - Decide on
sourcesContent. For private application code, setsourcemapExcludeSources: true(Vite/Rollup) or--sources-content=false(esbuild) so the.mapcarries mappings but not your original code. Keep it enabled only if the maps stay private and your tracker needs embedded sources. The trade-off is concrete: withsourcesContentstripped, the tracker resolves file and line but shows no code snippet unless it can read the source from your repository at that commit; with it embedded, the tracker shows the exact code but the map now contains your full source, so it must never leak. For most teams the right answer is strip-and-grant-repo-access, which keeps the smallest possible artifact public-adjacent while still giving triagers code context. - 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. The identifier must be computed identically at build time and at runtime — a common mistake is uploading maps under the full 40-character SHA while the runtime reports a 7-character short SHA, which the tracker treats as a different release and fails to match. Pick one canonical form and thread it through both the upload command and the client SDK’s
releasefield. - 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. This step needs a write-scoped auth token held only in CI secrets, never in the shipped bundle; the token authorizes uploading artifacts to a release, which is a privileged operation you do not want exposed. Upload before you delete, and upload before you flip live traffic to the new release, so that the first error from the new deploy already has a map waiting for it.
- Delete maps from the deploy artifact after upload so they never reach the public bucket:
find dist -name '*.map' -delete. Run this against the exact directory tree that gets synced to the CDN, not a copy — if your deploy step archivesdistbefore the delete runs, the archive still carries the maps. Ordering matters: delete strictly after a confirmed-successful upload, or a transient upload failure will leave you with neither public maps nor tracker maps and an un-debuggable release. - 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. Do this verification on every first deploy of a new tool version and after any change to the transform chain, because that is exactly when composition silently breaks. A throwaway route that callsthrow new Error('sourcemap canary ' + RELEASE)behind an internal flag is a cheap permanent smoke test; wire it to fail the deploy if the resolved frame does not point at the canary’s own source file.
CI integration
The workflow above is only reliable when it is encoded as build steps rather than tribal knowledge, because every manual step is a step someone will skip under deadline pressure. The shape that holds up is: build with hidden maps, assert no pragma leaked, upload maps keyed by the release, then strip maps from the publish artifact — with the assertion as a hard gate that fails the pipeline. The following sketch wires those steps together for a Vite build; the same sequence applies to any tool by swapping the build command and the emit-mode flag.
#!/usr/bin/env bash
# ci-build-and-upload.sh — Vite 6.x, Node 20+, Sentry CLI 2.x
set -euo pipefail
RELEASE="$(git rev-parse HEAD)" # one canonical form, used everywhere
# 1. Build with hidden maps (sourcemap: 'hidden' set in vite.config.ts).
vite build
# 2. Hard gate: no bundle may advertise a map. Fail the pipeline if any does.
if grep -rl "sourceMappingURL" dist/assets/*.js; then
echo "ERROR: a bundle leaked a sourceMappingURL pragma" >&2
exit 1
fi
# 3. Upload maps to the tracker, keyed by the release, before going live.
sentry-cli sourcemaps upload \
--release "$RELEASE" \
--strip-prefix "$(pwd)" \
./dist/assets
# 4. Strip maps from the artifact that will be synced to the CDN.
find dist -name '*.map' -delete
# 5. Publish only after upload+strip succeeded (set -e guarantees ordering).
echo "safe to deploy release $RELEASE"
The set -euo pipefail line is doing real work here: it guarantees that a failed upload aborts the script before the deploy, preserving the invariant that maps reach the tracker before the release goes live and never reach the public bucket. Injecting RELEASE into the client SDK’s release field (via a build-time define such as import.meta.env.VITE_RELEASE) closes the loop so runtime errors carry the same identifier the maps were filed under.
Debugging & Failure Modes
Wrong source paths in the resolved stack
Symptom: 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, so the stack is technically symbolicated but the “view source” link is dead. Root cause: the sources[] array records paths relative to the bundler’s working directory rather than the project root, and that working directory differs between your laptop and the CI runner, so the recorded path is machine-specific. Fix: normalize the paths with sourcemapPathTransform (Rollup) or run the upload from the repository root with a --url-prefix/--strip-prefix that aligns the recorded paths to what the tracker stores. Confirm: open the raw .map, read the sources[] array, and check that each entry is a clean repo-relative path like src/app.ts with no leading ../ or absolute prefix — the array is the ground truth, and if it is clean the resolved links will be too.
Missing sourcesContent
Symptom: the tracker symbolicates to the right file and line but shows no code context, only // no source available where the surrounding lines should be. Root cause: sourcesContent was stripped (or never emitted) and the tracker cannot fetch the original from your repo, so it has coordinates but no text to display at them. Fix: either keep sourcesContent (accepting that the private maps embed code and must never leak) or configure the tracker with read access to the source at that exact commit via a repository integration. Confirm: grep the .map for "sourcesContent" — if the key is absent or its array holds nulls, the map carries no code, and the fix must come from the tracker’s repo access, not from re-enabling embedding on a public map. Do not “fix” this by shipping public maps; that trades a cosmetic gap for a source-disclosure incident.
Leaked maps in production
Symptom: 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 and returns JSON. Anyone can now reconstruct your unminified, commented source, including any secrets or business logic the minifier happened to preserve as string literals. Root cause: sourcemap: true (not 'hidden') or a deploy step that copied .map files to the public bucket after the build. Fix: switch to hidden mode and add the find dist -name '*.map' -delete step after upload, then invalidate the CDN cache so the already-served pragma and map disappear. Confirm: re-run the curl against a fresh path and confirm both the pragma and the .map fetch are gone. Treat a leaked map as a source-disclosure incident, not a cosmetic bug — file it, rotate anything the exposed source revealed, and check access logs for whether the .map was fetched by anyone other than your own monitoring.
Off-by-N mappings from an un-composed transform
Symptom: lines resolve to the correct file but are consistently shifted by the same offset, and the offset grows the deeper into the file the frame is. Root cause: a transform in the chain (a custom Babel plugin, a string-replace step, a banner or license-header injection) rewrote code without emitting or composing its own map, so composition treated that stage’s output as original and every downstream position inherited the drift. Fix: audit every plugin and transform between source and output and ensure each returns { code, map }; a plugin that returns only code silently invalidates downstream mappings. Confirm: bisect the transform chain by disabling plugins one at a time and re-checking a known frame, or add a banner injection and verify the shift changes by exactly the banner’s line count — if it does, that stage is the un-composed one. A plugin that inserts a fixed number of leading lines is the easiest case to spot because the offset equals the lines it added.
Inline map accidentally shipped to production
Symptom: the served JavaScript bundle is far larger than the reported chunk size and its tail contains a //# sourceMappingURL=data:application/json;base64, URI thousands of characters long. Root cause: an emit mode of 'inline' (Vite/Rollup) or inline/both (esbuild) left over from a debugging session, which embeds the entire map inside the shipped file. Fix: switch to 'hidden'/external so the map is a separate, unshipped file. Confirm: grep -c "sourceMappingURL=data:" dist/assets/*.js must return zero, and the served bundle size should drop back to roughly the minified code size. This is both a performance regression (users download the map) and a source-disclosure one (the map is public), so it fails the same CI gate as a leaked external map.
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. The cost is dominated by the VLQ encoding and the composition join across transforms, both of which scale with the number of mappings rather than raw byte count, so a heavily-split build with many small chunks pays more than a single large bundle of the same total size. This is worth budgeting for in CI: if your pipeline has a wall-clock ceiling, the map cost is real and lands on every build, whereas the upload cost lands only on releases.
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). The upload itself is usually the slower half of the map budget on a large app, because it is network-bound and often serialized behind an auth handshake per release; parallelizing the upload across chunks and stripping sourcesContent when the tracker has repo access are the two levers that move it.
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. Run that comparison before assuming maps are your build’s bottleneck; on many projects the transpile and minify phases dominate and the map serialization is noise, in which case there is no reason to trade away debuggability to shave it.
When not to emit source maps
There is no good reason to ship inline maps to production, and only two situations where emitting no map at all is defensible. The first is a fully internal or air-gapped build where no error tracker exists and every operator has the exact source checkout, so symbolication happens locally against a rebuilt artifact; even here, hidden maps stored as a CI artifact are usually worth the negligible cost. The second is a hard constraint that forbids the source, in any form, from leaving the build machine — some regulated environments treat even a private, tracker-held map as an unacceptable copy of the source. In that case, keep maps entirely on the build host, symbolicate offline by feeding the stored map and the minified stack to a local resolver, and never upload. For every ordinary public web application the answer is hidden maps with sourcesContent stripped and repo access granted to the tracker; “no map” is a decision to operate blind, and it should be made deliberately, not inherited from a default.
Compatibility Matrix
hidden, esbuild external, Turbopack default.| 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) |
The row that catches people is esbuild: its “hidden equivalent” is the mode literally named external, which is the same word Vite and Rollup use for the pragma-carrying true behavior. The underlying map format is identical across all four tools — Source Map v3 with Base64 VLQ mappings — so a map produced by any of them resolves against any compliant tracker or DevTools; the only thing that varies is the emit-mode vocabulary and where sourcesContent control lives. Read the matrix as a translation table between four spellings of the same two decisions: whether a pragma ships, and whether original code is embedded.
Related
- Core Concepts of Modern Bundling — the parent overview covering the compilation pipeline these maps annotate.
- Uploading source maps to Sentry from a Vite build — the concrete hidden-map upload and verification workflow for one error tracker.
- Bundler Version Compatibility Reference — version pairings that determine which source map modes and flags are available.
- Dynamic import() Code Splitting Patterns for React — chunked output that makes accurate per-chunk maps essential for symbolication.