Source Maps and Symbolication Explained
A minified stack trace points into compiled output. Symbolication maps it back to your source. Here is how source maps, VLQ mappings and debug IDs work.
A production stack trace does not point at your code. It points at whatever your build emitted: a column in a minified bundle, an offset into stripped machine code. Symbolication is the process of translating those positions back into files, lines and function names, using debug information your build produced and then threw away. For JavaScript that debug information is a source map.
This is a first-principles walkthrough: what is actually inside a source map, how to decode one segment by hand, how a map gets matched to the build that produced it, and why symbolication turns out to be a caching problem more than a parsing one.
Key takeaways
- A source map is a JSON file whose
mappingsfield is a compact list of (generated position → original position) pairs encoded as Base64 VLQ deltas. - Matching a map to the build it came from is the part that actually breaks in production. Debug IDs fix it; filenames only work if they are content-hashed.
- Never ship source maps to your users. Upload them out of band from CI.
- The minified bundle is worth uploading next to the map: it is what recovers function names.
- TracePath symbolicates once, at ingest, and stores the resolved trace. Maps uploaded later improve future events, not past ones.
What the browser reports
A release build turns readable code into this:
TypeError: Cannot read properties of undefined (reading 'name')
anonymous()
app.bundle.js:1:48211
anonymous()
app.bundle.js:1:13377Line 1 in a single-line bundle, two anonymous functions, no idea which component threw. There is one useful signal in there, the column, and one useful fact: that column is a deterministic position in an artifact you built, so something you produced at build time can translate it.
That something is the source map. With it uploaded, the same trace reads:
TypeError: Cannot read properties of undefined (reading 'name')
renderUserCard()
src/components/UserCard.tsx:42:17
handleClick()
src/pages/Profile.tsx:108:5What is actually in a source map
A .map file is JSON with a handful of fields:
{
"version": 3,
"file": "app.bundle.js",
"sourceRoot": "",
"sources": ["src/pages/Profile.tsx", "src/components/UserCard.tsx"],
"sourcesContent": ["import React from ...", "export function ..."],
"names": ["renderUserCard", "user", "handleClick"],
"mappings": "AAAA,SAASA,cAAT,CAAwBC,IAAxB..."
}sources is the list of original files. sourcesContent optionally embeds
their text, which is what lets a tool show you the offending line without
fetching anything. names is a string table of original identifiers. Everything
interesting lives in mappings.
Decoding mappings by hand
mappings is one long string with two levels of structure:
- Semicolons separate generated lines. A run of
;;;means three generated lines with no mappings at all. - Commas separate segments within a generated line. Each segment describes one position.
A segment is 1, 4 or 5 Base64 VLQ numbers:
| Fields | Meaning |
|---|---|
| 1 | generated column only (this position maps to nothing) |
| 4 | generated column, source index, original line, original column |
| 5 | the above plus an index into names |
Every field except the first is a delta from the previous segment, carried across the whole file. The generated column resets to zero at each semicolon; source index, original line and original column do not. That is the entire compression trick, and it is why you cannot read a source map from the middle.
Base64 VLQ itself is small enough to do on paper. Each Base64 digit carries six bits: five bits of payload and a continuation flag at value 32. Digits are little-endian, so the first digit holds the least significant bits. In the assembled value, the lowest bit is the sign and everything above it is the magnitude.
Take the segment AAgBI:
| Digit | Base64 value | Bits | Meaning |
|---|---|---|---|
A | 0 | 000000 | no continuation, payload 00000 |
A | 0 | 000000 | no continuation, payload 00000 |
g | 32 | 100000 | continuation set, payload 00000 |
B | 1 | 000001 | no continuation, payload 00001 |
I | 8 | 001000 | no continuation, payload 01000 |
Field by field:
A→ payload0. Sign bit0, magnitude0. Generated column delta: 0.A→ source index delta: 0, so this segment maps intosources[0].gsets the continuation flag, soBjoins it. Low five bits00000fromg, next five bits00001fromB, giving1 << 5 = 32. Sign bit0, magnitude32 >> 1 = 16. Original line delta: +16.I→ payload8. Sign bit0, magnitude8 >> 1 = 4. Original column delta: +4.
Four fields, so no names entry. If this were the first segment of the first
generated line, it maps generated 1:1 to src/pages/Profile.tsx line 17,
column 5, once you convert the zero-based line and column the spec uses into the
one-based numbers an editor shows.
Multiply that by a few hundred thousand segments and you have a production source map. Decoding is cheap per segment and ruinous in bulk, which is the whole reason for the caching discussion below.
Looking up a frame
Mappings are sparse. There is no segment for every column, so a lookup is not an exact match: you find the last segment whose generated position is less than or equal to the reported one, on the same generated line, and that segment's original position is your answer. Because segments are sorted, that is a binary search over a decoded array.
Function names are a separate problem. The names table only carries a name
when the segment references one, and a minified frame's function name is usually
absent or wrong. Recovering it properly means looking at the minified bundle
and working out which function scope encloses the reported position. This is why
it is worth uploading the .js file alongside its .map: with only the map you
get file, line and column, and the function name stays whatever the browser
guessed.
Matching a map to a build
Decoding is the easy half. The half that breaks in production is deciding which map applies to a given frame.
Do not ship sourceMappingURL to users
The obvious approach, leaving a //# sourceMappingURL=app.bundle.js.map
comment in the bundle and serving the map from your CDN, publishes your
unminified source to anyone who opens devtools. It also makes symbolication
depend on your CDN being reachable and on the map still being there weeks after
the deploy that produced it.
Upload maps out of band instead: your build produces them, CI ships them to your
error tracker, and the artifacts your users download never reference them. In
TracePath that is a POST to /api/sourcemaps/upload on app.tracepath.dev,
authenticated with a per-project source-map upload token you generate on the
project's Connection page. It is a separate credential from the project token
your instrumentation uses, so a leaked CI secret cannot be used to write
telemetry, and regenerating it invalidates the old one immediately.
Filename matching, and where it fails
The simplest match is by name: an exception reports app.bundle.js, so use the
map uploaded as app.bundle.js.map. TracePath reduces the frame's file to its
basename first, stripping any origin, query string or fragment, so
https://cdn.example.com/assets/app.bundle.js?v=abc123 and plain
app.bundle.js resolve to the same key.
This works perfectly if your bundle names contain a content hash, which is the
default in Vite, Next.js, Angular and most modern bundlers: every build produces
a distinct filename, so every build's maps stay distinct. It falls apart the
moment two builds share a name. Deploy twice in an hour behind a stable
app.js, and users on the old bundle get symbolicated against the new map. The
frames resolve. They just point at the wrong lines, which is worse than not
resolving at all, because nothing tells you it happened.
Debug IDs
A debug ID is a 128-bit identifier embedded by the build into both the
generated file and its map. The bundle carries a //# debugId=<uuid> comment;
the map carries a matching debugId field. At runtime the reporting SDK sends
the IDs of the bundles involved in the exception, and the backend picks the map
with that exact ID.
That makes matching content-addressed. It no longer depends on filenames, URLs,
versions, deploy order or clock skew. Two concurrent deploys of app.js carry
two different debug IDs, and each frame resolves against the artifact it
actually came from.
This is standardized as the Debug ID proposal in the ECMA-426 source map specification, which means it is not a per-vendor trick: artifacts produced by any debug-ID-aware bundler plugin carry the same format, and TracePath consumes them regardless of which tool injected them. TracePath tries debug ID first and falls back to filename matching when a frame has no ID or no artifact was uploaded under it, so adding debug IDs to an existing pipeline is incremental.
Symbolication is a cache problem
Once matching is solved, the remaining cost is arithmetic at scale. Parsing a large source map and decoding every segment is expensive. Looking up one frame in an already-decoded map is a binary search. A tool that re-parses the map per frame is doing the expensive thing thousands of times to avoid doing it once.
The fix is to separate the two costs:
- Compile once per artifact. Parse the debug info, decode every mapping into a sorted range table, intern file and function names so a string used across thousands of ranges is stored once, and serialize the result.
- Cache the compiled form, not the raw map. It is smaller, it is already sorted, and it is designed for lookup rather than for transport.
- Look up per frame against the compiled artifact.
TracePath's symbolicator does exactly this, writing the compiled resolver to a
.tw file that can be memory-mapped and read in place: opening one validates a
header and then reads straight from mapped pages, with no decoding step, and the
operating system's page cache shares those pages across every process that maps
the same file. A hot artifact is served from an in-memory LRU; a warm one from
the local file; a cold one is pulled from object storage; only a genuinely new
artifact is compiled from scratch, and concurrent requests for the same one are
deduplicated so it is compiled once rather than once per request. The
architecture documentation has the
format details.
One consequence of this design is worth stating plainly, because it surprises people: TracePath symbolicates at ingest, once. The resolved trace is what gets hashed for grouping and what gets stored. Viewing an issue later does not re-resolve anything. Uploading a missing map improves every event that arrives after it, and does nothing for the ones already stored. So the upload belongs in your deploy pipeline, before the build goes live, not in the incident response you run afterwards.
The same problem outside JavaScript
Source maps are the JavaScript answer to a question every compiled language has to answer, and the shape of the solution repeats:
| Platform | Debug info | What the raw trace looks like |
|---|---|---|
| JavaScript / TypeScript | .map source map | app.bundle.js:1:48211 |
| Flutter / Dart AOT | the build's .symbols file | an offset into an instructions section |
| iOS (Swift, Objective-C) | .dSYM (DWARF in a Mach-O wrapper) | an image UUID plus an image-relative offset |
| Android (Kotlin, Java) | R8 mapping.txt | obfuscated class and method names, SourceFile:2 |
The mechanics differ, the discipline does not. Every one of them requires that you keep the debug artifact from the exact build you shipped, and upload it before the crashes arrive. A dSYM from a rebuild of the same commit will not match, because the UUID changed. Debug IDs, image UUIDs and build IDs are all the same idea: make the artifact identify itself so no human has to remember which build was live.
A CI checklist
- Turn map emission on. Most bundlers do not write
.mapfiles for a production build unless you ask:build.sourcemapin Vite,devtool: "source-map"in webpack,productionBrowserSourceMapsin Next.js. - Confirm they exist before uploading. An uploader that finds nothing
usually exits zero, so a silent misconfiguration looks like a green build.
Assert that
ls dist/**/*.mapis non-empty. - Inject debug IDs at build time if your bundle names are not content-hashed. If they are, you can add this later.
- Upload maps and their sibling bundles together so function names resolve.
- Upload before the deploy goes live, not after. Symbolication is a write-time operation.
- Do not publish the maps to your CDN. Ship only what the browser needs.
- Verify once per pipeline change by throwing a deliberate error in a staging build and reading the resulting issue.
FAQ
Do I need source maps if my bundle is not minified? No. If frames already report your real file names and line numbers, there is nothing to translate. This is rare in production builds, and it costs you bundle size and load time to keep it that way.
Are source maps safe to upload to a vendor? They contain your original source, so treat them as source code. That is exactly why they should not be served publicly from your CDN. Upload them over TLS with a dedicated, revocable token rather than a general-purpose one.
How large can a map be? TracePath accepts individual files up to 50 MB, and a single upload request up to 250 MB and 1,000 files, maps and bundles combined. A large monorepo build can reach the request total, so split the upload by directory if your CI hits it.
What if my maps have no source file names?
Some pipelines emit maps whose sources array contains nulls: the mappings are
intact but the file names are gone. Frames still resolve to a line, a column and
a function name, with <unknown> in place of the file. Fixing it is a bundler
setting, not an upload setting.
Why did an old issue not improve after I uploaded the map? Because stored traces are immutable snapshots of what was resolvable when they arrived. Trigger the error again on the current build and the new event will be symbolicated.
Next
- Symbolication in TracePath and how stack traces are grouped
- Per-platform symbolication details for JavaScript, Dart, iOS and Android
- Plans and ingest allowances
- How TracePath compares to Sentry