Source Maps
The same problem as a DWARF line table, solved in JSON for pipelines that emit source rather than machine code. The two things worth understanding are the VLQ encoding that makes the mappings small, and the composition rule that makes a chain of four tools still point at your original file.
How does the browser show me my TypeScript when what is running is a minified bundle?
A JSON side-file describing a mapping between two texts: the generated output and one or more original sources. Its core is the mappings string — a Base64 VLQ encoding of a list of segments, each recording a generated position and, usually, the original file, line, column and symbol name it came from. It is a line table for a source-to-source compilation, and the interesting difference from DWARF is that the "addresses" are line and column positions in a text file rather than machine addresses.
A source map is metadata: producing it must not change the emitted code, so a build with and without --sourcemap must ship byte-identical output. A map is *valid* only if every segment it contains points at a position that actually produced the generated text at that position — and the composition precondition is the one that matters in practice: when several tools run in sequence, each tool after the first must consume its input's map and emit a map to the *original* sources, not to its own input. A tool that ignores the incoming map produces a technically well-formed map that points at an intermediate artifact nobody has, which is indistinguishable from a broken one at the point of use.
Key points
- A source map is a line table for a source-to-source pipeline: generated position in, original file/line/column out.
- The
mappingsfield is a Base64 VLQ delta encoding —;separates generated lines,,separates segments, and every field is relative to the previous. - Because the encoding is a delta stream, a corrupt segment shifts every position after it, so a broken map is usually broken throughout.
- The
namesarray is the only variable information a source map carries; there are no types and no scopes, unlike DWARF. - The dominant real-world failure is composition: a stage that maps to its own input instead of through the incoming map severs the chain to the original source.
- Diagnose composition failures by reading the final map's
sourcesarray — it names the stage that dropped the chain. sourcesContentembeds the original text, which makes maps portable and publishes your source if you serve them.- Generating a map must not change the emitted code, exactly as
-gmust not change the instructions. - The production pattern mirrors symbolication: generate, upload keyed by release, do not serve publicly unless you mean to.
The same problem, a different container
A JavaScript pipeline destroys source correspondence exactly the way a compiler backend does. TypeScript erases types and lowers syntax. Babel rewrites modern constructs into older ones. A bundler concatenates hundreds of modules into one file and rewrites their imports. A minifier renames every local variable to one letter, removes whitespace, inlines functions and collapses the result onto a handful of extremely long lines. What executes bears no textual resemblance to what anybody wrote.
The answer is structurally identical to [[debug-information]]: emit a mapping alongside the output. What differs is the container and the coordinate system. Instead of DWARF sections keyed by machine address, a JSON file keyed by generated line and column; instead of a state-machine program, a delta-encoded string; instead of a debugger reading ELF, a browser devtools panel reading a //# sourceMappingURL= comment at the end of the file.
It is worth holding the two together, because the shared structure is the lesson. Both exist because a stage discarded information a human will need later. Both are optional, generated only on request, and both are useless if the artifact that consumes them cannot find them. Both degrade under aggressive transformation. And both have the property that a *chain* of stages requires each stage to compose its mapping with the previous one — which DWARF gets for free because there is one compiler, and which JavaScript does not, because there are four tools from four vendors.
| Aspect | DWARF line table | Source map v3 |
|---|---|---|
| Maps from | Machine address | Generated line and column |
| Maps to | File, line, column | Source index, line, column, optional name |
| Encoding | A state-machine program of deltas | Base64 VLQ deltas in one string |
| Carried in | Sections of the object or a separate .dSYM/PDB | A .map file or a data: URI in a trailing comment |
| Variable locations | Yes — location lists per address range | No. Only a names array for renamed identifiers |
| Types | Yes — full type and scope tree | No |
| Composition across stages | Internal to one compiler | Each tool must consume and re-emit — and often does not |
Inside the mappings string
ignoreList (marking third-party sources so devtools can hide them) and index maps with a sections array are both in the specification and implemented unevenly. A consumer must tolerate their absence, and a producer cannot rely on them being honoured.The mappings field is where the format earns its size, and understanding it takes about five minutes and removes all the mystery from a corrupt map.
The string is split by ; into groups, one per generated line — so the count of semicolons tells you how many lines the output has. Within a group, , separates segments, each describing one position in that line. A segment is one, four or five numbers: generated column; index into sources; original line; original column; and optionally an index into names. A one-field segment means "generated code here maps to nothing", which is how you mark output the tools invented.
Every number is relative to the previous one — that is the compression. Generated column resets at each new line; source index, original line and original column carry over across lines and continue accumulating. Then each number is encoded as Base64 VLQ: split into six-bit groups, the low bit of the first group is the sign, the top bit of each group says "another group follows", and the six-bit values are indexed into the standard Base64 alphabet. So a delta of zero is A, one is C, minus one is D.
Two practical consequences fall out of the delta encoding. First, the map is a *stream* — corrupt or drop one segment and every subsequent position in the file is shifted, which is why a broken map is usually broken everywhere rather than in one place. Second, the encoding is why maps are still large despite being deltas: a minified bundle has a segment for essentially every token, and the .map file is routinely bigger than the code it describes.
1{2 "version": 3,3 "file": "out.js",4 "sourceRoot": "",5 "sources": ["src/a.ts", "src/b.ts"],6 "sourcesContent": ["export const a = 1\n", null],7 "names": ["compute", "total"],8 "mappings": "AAAA,SAACA,QAAQ;AACR,ICAAC"9}10 11Segment "AAAA" -> [0, 0, 0, 0]12 generated column 0 (+0)13 sources[0] = src/a.ts (+0)14 original line 0 (+0)15 original column 0 (+0)16 17Segment "SAACA" -> [9, 0, 0, 1, 0]18 generated column 0 + 9 = 919 sources[0 + 0] = src/a.ts20 original line 0 + 0 = 021 original column 0 + 1 = 122 names[0] = "compute" <- the original identifier before minificationNote sourcesContent: embedding the original text means devtools can show your source without fetching it, which is the difference between a map that works for a user and one that works only on the machine that has the repository. The null entry shows it is per-source. And note the names array — that is the entire mechanism by which a debugger can tell you that the variable now called t was called total.
Composition is where it actually breaks
The single most common source-map failure in practice is not a malformed map. It is a correct map that points at the wrong thing, because a chain of tools did not compose.
Consider a realistic pipeline: TypeScript compiles src/app.ts to build/app.js and emits app.js.map pointing at the .ts. A bundler concatenates build/*.js into bundle.js and emits bundle.js.map. A minifier turns that into bundle.min.js and emits bundle.min.js.map. If each stage only maps to *its own input*, the final map points at bundle.js — an intermediate artifact that exists on the build machine and nowhere else. The browser dutifully shows you a file it cannot fetch, or shows you bundled-but-unminified JavaScript that is not your source.
The correct behaviour is that each stage reads the incoming map and emits a map composed through it, so the final map points all the way back to src/app.ts. Modern tools do this by default when they can find the input map, and the failure modes are all about *finding* it: an intermediate step that strips the sourceMappingURL comment, a build that pipes through stdout and loses the file, a tool configured with an input map path that no longer exists, a copy step that moves the JavaScript and not the .map.
The diagnosis is mechanical, and worth knowing because it takes two minutes. Open the final .map and read its sources array. If it lists your original files, composition worked. If it lists an intermediate bundle, you have found the stage that dropped the chain — it is the one whose output those names correspond to.
- Authoryou write itTypeScript source in
src/ - tscbuild timeJavaScript in
build/, plus a map tosrc/A mapping from emitted JS positions back to TS positions.Types entirely — they exist in no later artifact. - Bundlerbuild timeOne
bundle.js, plus a mapModule resolution and a single output file.Module boundaries as file boundaries; if it ignores the incoming map, the link back tosrc/dies here. - Minifierbuild time
bundle.min.js, plus a mapRenaming, inlining, whitespace removal — the largest single loss of correspondence.All identifier names, unless they are preserved in the map'snamesarray. - Browserrun timeExecuting minified code; devtools showing original sourceThe reverse mapping, applied live to stack traces and breakpoints.Nothing further — but it can only show what the final map points at.
Read it asRead the loses column as a list of places the chain can be cut. Any stage that emits a map to its own input rather than composing through the incoming one severs everything above it, and the symptom appears only at the last stage — in the browser — which is why the debugging instinct of "check the last tool" is usually wrong. Check the sources array of the final map and it names the guilty stage directly.
Production, privacy and stack traces
Source maps in production are the same decision as shipping debug information, with the same right answer and a sharper privacy edge. A minified stack trace from a real user is a list of positions in bundle.min.js at line 1, column 84213 — useless without a map. So error-reporting services want the map, and the standard arrangement mirrors [[symbolication]] exactly: generate the map, upload it to the error reporter keyed by a release identifier, and do *not* serve it publicly.
The privacy edge is that a source map with sourcesContent embeds your original source, comments included. Serving it from the public origin publishes your codebase. Serving the .map but not the sourcesContent publishes your file and directory names. Both are choices rather than accidents, and both are made by default by a build configured without thinking about it. The options are: do not emit the sourceMappingURL comment in production and upload the map out of band; or serve the map from an authenticated path; or accept the disclosure deliberately, which many open-source projects reasonably do.
Node has a directly useful counterpart worth knowing: node --enable-source-maps makes the runtime apply source maps to stack traces itself, so an unhandled exception in a compiled TypeScript service prints .ts positions. It has been available since Node 12.12 and is off by default for the cost of loading and applying the maps.
- Generate maps for production builds. Not shipping them publicly is a separate decision from not generating them.
- Upload maps to the error reporter keyed by a release or build identifier — the same pattern as archiving symbols by build ID.
- Omit the trailing
sourceMappingURLcomment on public bundles if you do not intend to serve the map. sourcesContentembeds your source text. Decide about it explicitly rather than by default.node --enable-source-mapsgives original positions in server-side stack traces.- Check the final map's
sourcesarray in CI. It is a one-line assertion that catches every composition failure.
How it works
The steps, in the order the compiler takes them.
- A tool that transforms source records, for each piece of output it emits, the position in its input that produced it.
- If an input source map was supplied, each recorded input position is looked up in it and replaced by the original position it maps to, so the new map composes through the old one.
- Positions are collected as segments, sorted by generated line and column, and converted into deltas from the preceding segment.
- Each delta is encoded as Base64 VLQ, segments are joined with commas and lines with semicolons, producing the
mappingsstring. - The source file list, optional embedded contents and the array of original identifier names are written alongside it as JSON.
- The generated file gets a trailing
//# sourceMappingURL=comment naming the map, or embedding it as adata:URI. - A consumer — devtools, a Node runtime, an error reporter — parses the mappings into a searchable structure and, given a generated position, binary-searches for the segment at or before it.
- Stack frames, breakpoints and the displayed source are then rewritten through that lookup, with the
namesarray supplying the original identifier where one was recorded.
How it breaks
What the engineer observes when it goes wrong — not what goes wrong internally.
- Devtools shows bundled but unminified JavaScript instead of your TypeScript, because one stage in the chain mapped to its own input rather than composing.
- The browser reports a missing source map because a deployment step copied the JavaScript and not the
.map, leaving asourceMappingURLpointing at a 404. - Every mapped position is off by a constant amount, because a step prepended a banner comment or a license header after the map was generated.
- A production stack trace symbolicates to the wrong lines, because the uploaded map is from a different build than the deployed bundle and nothing keyed them together.
- Your entire source, comments included, is publicly readable because
sourcesContentwas embedded and the.mapis served from the CDN. - Breakpoints set in the original file never bind, because the mapping is line-only and the position the debugger needs is mid-line in a minified single-line bundle.
- The
.mapis several megabytes and is fetched by every developer opening devtools on the production site, because it was served unconditionally.
When it helps
- Any pipeline where the executed artifact is not the authored one: TypeScript, Babel, JSX, bundlers, minifiers, CSS preprocessors, and compile-to-JavaScript languages generally.
- Production error reporting, where the difference between an actionable stack trace and a column number in a minified file is entirely this file.
- Server-side TypeScript with
node --enable-source-maps, where it converts every unhandled rejection into something you can act on. - Debugging a build pipeline itself: reading the map tells you exactly what each stage did to a given construct.
When it hurts
- When the map is served publicly and the source was not meant to be. This is a disclosure, not a performance problem, and it is the most common unintended consequence.
- On very large bundles, where the map can exceed the bundle size and slow devtools noticeably on load.
- After aggressive minification with inlining and cross-module optimization, where the mapping degrades in exactly the way DWARF does at
-O2— see[[debugging-optimized-code]]for the same phenomenon on the native side. - When it creates false confidence: a map showing your original source does not mean the running code corresponds to it construct for construct, only that a position was recorded.
What it costs
Every one of these is paid by something.
- Generating maps buys interpretable production errors and pays build time plus an artifact frequently larger than the code, which must then be stored and served or uploaded.
- Embedding
sourcesContentbuys maps that work anywhere without fetching the repository and pays the full disclosure of your source text to anyone who obtains the map. - Inline
data:URI maps buy a single artifact with nothing to lose in deployment and pay a much larger JavaScript file that ships to every user. - High-fidelity maps from a minifier buy accurate stack traces and cost minification opportunities, since some transformations have no expressible mapping at all.
- Uploading maps to an error reporter buys symbolicated production traces and pays an upload step that must be keyed to the release, plus the operational burden of keeping the association correct.
- Serving maps only to authenticated users buys privacy and pays infrastructure — a separate path, an auth check, and a devtools experience that does not work out of the box.
What else you could do
What a different compiler or language does instead, and when that is better.
- Do not minify. The bundle is larger and the stack traces are readable without any extra artifact, which is a legitimate trade for internal tools and small services.
- Ship unminified but keep the map for a separate minified build served to production only, so development is direct and production is mapped.
- Preserve function names through minification (
keep_fnames,keep_classnames) so stack traces are partially readable with no map at all. Costs bundle size and gives lines but not source. - Server-side symbolication in the error reporter, uploading the map once per release rather than serving it — which is the standard production arrangement and the direct analogue of
[[symbolication]]. - Compile-time source embedding: some toolchains can emit the original source as comments or a sidecar, which is cruder than a map and survives pipelines that would break composition.
- On the native side, this whole problem is solved by DWARF instead — see
[[debug-information]]. The comparison is worth making because it shows the JSON format is a container choice rather than a different idea.
See it for yourself
The flag, dump or tool that shows you this directly.
- Generate them:
tsc --sourceMap --inlineSources,esbuild --sourcemap=external,webpackwithdevtool: "source-map",vite build --sourcemap,terser in.js --source-map "content=in.js.map,url=out.js.map". - Read one directly: it is JSON, so
jq '.sources, .sourcesContent | length' out.js.mapanswers the two most useful questions — where does it point, and does it embed the text. - Decode a position by hand once.
npx source-map-cliand thesource-mapnpm package'sSourceMapConsumerboth resolve a generated line/column to the original; doing it once makes the VLQ format concrete. - Browser: DevTools shows mapped sources under the original paths and reports map-loading failures in the console; the Sources panel's "page" versus "authored" trees make composition failures obvious at a glance.
- Node:
node --enable-source-maps app.jsapplies maps to stack traces at runtime;NODE_OPTIONS=--enable-source-mapsfor a process you do not launch directly. - Bundle analysis:
source-map-explorer bundle.js bundle.js.mapuses the map to attribute every byte of the bundle to an original file, which is the best available answer to "why is this bundle so big". - Production upload:
sentry-cli sourcemaps upload --release <id> ./distor the equivalent for your reporter, and assert in CI that the final map'ssourcesarray names your real files.
Plausible wrong readings
Stated the way a confident engineer states them.
- "The browser is running my TypeScript." It is running the minified JavaScript. The map only changes what devtools *displays* and how stack frames are reported.
- "Source maps make the bundle slower." The bundle is byte-identical; the map is a separate file fetched only when a developer opens devtools, or by the error reporter out of band.
- "If devtools shows my source, the maps are correct." It shows what the final map points at. Verify by reading the
sourcesarray, and by checking that a breakpoint actually binds where you set it. - "A source map is like DWARF." It is like the DWARF *line table* specifically. There are no variable locations, no types and no scopes — only positions and an array of original names.
- "We disabled source maps in production for performance." Almost certainly the intent was privacy or artifact size. Generate them and upload them out of band; you lose nothing at runtime and gain readable crash reports.
Misconceptions
The claim, and what is actually true.
-O2.sourcesContent they publish your source text; without it they publish your file and directory structure. Either may be fine — it should be a decision.Go deeper
The same idea at increasing depth. Stop wherever it stops being useful.
overview
What runs in a browser is usually not what anyone wrote: TypeScript was compiled, modules were bundled into one file, and a minifier renamed every variable to a single letter. A source map is a small JSON file generated alongside that output which says, for each position in the generated code, which file, line and column it came from. Devtools reads it and shows you your original source; error reporters use it to turn a crash at "line 1, column 84213" into something you can act on. It is exactly what a compiler emits for a native debugger, in a different container.
practical
Generate maps in production builds — the shipped JavaScript is byte-identical either way — and then decide separately whether to serve them. The usual answer is not to: omit the trailing sourceMappingURL comment on the public bundle and upload the map to your error reporter keyed by the release. When mapping goes wrong, do not guess at the last tool in the chain; open the final .map and read its sources array. If it names an intermediate bundle instead of your files, the stage that produced those names is the one that failed to compose. That check is a one-line CI assertion and it catches the whole failure class.
advanced
The comparison with DWARF is the part worth carrying away, because it exposes what source maps deliberately do not do. DWARF carries a full type and scope tree and a per-variable location list, so a native debugger can evaluate an expression in the original language. A source map carries positions and an array of original names, and nothing else — so devtools shows original source but evaluates expressions against the *generated* scope, which is why a variable that was inlined or renamed can be visible in the displayed source and unavailable in the console. There is a long-running effort to extend the format with scope information for exactly this reason. The structural point is that both formats are lossy inverses of a transformation, and both degrade in proportion to how aggressive the transformation was; the difference in what they can recover is a difference in how much the format chose to record, not in how hard the problem is.
How much this depends on
Nothing in this domain is true of every compiler. These say how much.
ignoreList and index maps with sections are specified but supported unevenly, so a producer cannot rely on them being honoured by every consumer.esbuild prioritises build speed and emits a coarser map than tsc; terser and other minifiers lose the ability to map some constructs after inlining regardless of settings; webpack's devtool values trade fidelity against build time explicitly, and eval-source-map produces something usable in development only. Comparing tools on "does it support source maps" is not a useful question; comparing on what a stack trace actually resolves to is.sources array in CI rather than assuming the default held.If you were asked this in an interview
- What is in the
mappingsfield of a source map, and why is it delta-encoded? - A production stack trace resolves to a bundled file instead of the original TypeScript. Where is the bug?
- What does a source map carry that DWARF does not, and what does DWARF carry that it does not?
Connections
- DevOps / Production Engineering — Uploading and keying build artifacts such as source maps and symbols to a release identifierA source map is only useful in production if the exact map for the exact deployed bundle can be found later, which makes it a release-artifact problem: generate, upload, key by release, retain for as long as the version is live. That pipeline is owned there; why the map exists and how it degrades is ours.
- Testing & Reliability Engineering — Asserting build-output properties in CIThe composition failure that breaks source maps is invisible until production and trivially catchable by a build-time assertion on the final map's source list. Deciding what a build pipeline should assert about its own artifacts is a testing-strategy question owned there; what specifically goes wrong with the mapping chain is ours.