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.

The question

How does the browser show me my TypeScript when what is running is a minified bundle?

SourceLexingTokensParsingASTSemanticsTypedIROptimizeCodegenMachine codeLinkExecute
What the program is here

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.

What this phase may assume or do

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 mappings field 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 names array 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 sources array — it names the stage that dropped the chain.
  • sourcesContent embeds 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 -g must 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.

Two solutions to one problemtypical
AspectDWARF line tableSource map v3
Maps fromMachine addressGenerated line and column
Maps toFile, line, columnSource index, line, column, optional name
EncodingA state-machine program of deltasBase64 VLQ deltas in one string
Carried inSections of the object or a separate .dSYM/PDBA .map file or a data: URI in a trailing comment
Variable locationsYes — location lists per address rangeNo. Only a names array for renamed identifiers
TypesYes — full type and scope treeNo
Composition across stagesInternal to one compilerEach tool must consume and re-emit — and often does not

Inside the mappings string

specThe v3 format is a published specification and is what every current tool emits; fields are zero-based for lines and columns, unlike most editor UIs and unlike the Debug Adapter Protocol. Later additions are not universally supported: 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.

A minimal source map, and the first segment decoded
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 = 9
19 sources[0 + 0] = src/a.ts
20 original line 0 + 0 = 0
21 original column 0 + 1 = 1
22 names[0] = "compute" <- the original identifier before minification

Note 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.

A four-stage pipeline, and what each stage owes the nexttypical
  1. Authoryou write it
    TypeScript source in src/
  2. tscbuild time
    JavaScript in build/, plus a map to src/
    A mapping from emitted JS positions back to TS positions.
    Types entirely — they exist in no later artifact.
  3. Bundlerbuild time
    One bundle.js, plus a map
    Module resolution and a single output file.
    Module boundaries as file boundaries; if it ignores the incoming map, the link back to src/ dies here.
  4. Minifierbuild time
    bundle.min.js, plus a map
    Renaming, inlining, whitespace removal — the largest single loss of correspondence.
    All identifier names, unless they are preserved in the map's names array.
  5. Browserrun time
    Executing minified code; devtools showing original source
    The 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 sourceMappingURL comment on public bundles if you do not intend to serve the map.
  • sourcesContent embeds your source text. Decide about it explicitly rather than by default.
  • node --enable-source-maps gives original positions in server-side stack traces.
  • Check the final map's sources array 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 mappings string.
  • 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 a data: 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 names array 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 a sourceMappingURL pointing 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 sourcesContent was embedded and the .map is 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 .map is 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 sourcesContent buys 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, webpack with devtool: "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.map answers the two most useful questions — where does it point, and does it embed the text.
  • Decode a position by hand once. npx source-map-cli and the source-map npm package's SourceMapConsumer both 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.js applies maps to stack traces at runtime; NODE_OPTIONS=--enable-source-maps for a process you do not launch directly.
  • Bundle analysis: source-map-explorer bundle.js bundle.js.map uses 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> ./dist or the equivalent for your reporter, and assert in CI that the final map's sources array 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 sources array, 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.

Source maps are a browser feature.
They are an artifact the build produces. Browsers, Node and error reporters are all consumers; if the build does not emit them, none of those consumers can do anything.
A source map lets you debug as if the original code were running.
It maps positions. Values, scopes and inlined structure are not represented, so the experience degrades under aggressive minification for exactly the reasons native debugging degrades at -O2.
Publishing source maps is harmless.
With 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.

specThe v3 format specifies zero-based lines and columns, the VLQ encoding and the field order within a segment. It deliberately says nothing about how a *chain* of tools should compose maps, which is exactly why composition is the common failure — each tool implements it by convention rather than by requirement. Newer optional fields such as ignoreList and index maps with sections are specified but supported unevenly, so a producer cannot rely on them being honoured by every consumer.
implementationMap fidelity varies sharply by tool and by configuration. 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.
typicalThe claim that modern tools compose maps by default describes current versions of tsc, esbuild, webpack, rollup, vite and terser when the input map is discoverable. It is discoverability that fails in practice: piping through stdout, copying the JavaScript without the map, or a step that strips the trailing comment all break composition silently. Assert the final sources array in CI rather than assuming the default held.

If you were asked this in an interview

  • What is in the mappings field 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

Domains that do not exist yet
  • DevOps / Production Engineering — Uploading and keying build artifacts such as source maps and symbols to a release identifier
    A 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 CI
    The 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.