All posts
Engineering

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 mappings field 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:13377

Line 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:5

What 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:

FieldsMeaning
1generated column only (this position maps to nothing)
4generated column, source index, original line, original column
5the 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:

DigitBase64 valueBitsMeaning
A0000000no continuation, payload 00000
A0000000no continuation, payload 00000
g32100000continuation set, payload 00000
B1000001no continuation, payload 00001
I8001000no continuation, payload 01000

Field by field:

  1. A → payload 0. Sign bit 0, magnitude 0. Generated column delta: 0.
  2. A → source index delta: 0, so this segment maps into sources[0].
  3. g sets the continuation flag, so B joins it. Low five bits 00000 from g, next five bits 00001 from B, giving 1 << 5 = 32. Sign bit 0, magnitude 32 >> 1 = 16. Original line delta: +16.
  4. I → payload 8. Sign bit 0, magnitude 8 >> 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:

  1. 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.
  2. 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.
  3. 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:

PlatformDebug infoWhat the raw trace looks like
JavaScript / TypeScript.map source mapapp.bundle.js:1:48211
Flutter / Dart AOTthe build's .symbols filean 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.txtobfuscated 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

  1. Turn map emission on. Most bundlers do not write .map files for a production build unless you ask: build.sourcemap in Vite, devtool: "source-map" in webpack, productionBrowserSourceMaps in Next.js.
  2. 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/**/*.map is non-empty.
  3. Inject debug IDs at build time if your bundle names are not content-hashed. If they are, you can add this later.
  4. Upload maps and their sibling bundles together so function names resolve.
  5. Upload before the deploy goes live, not after. Symbolication is a write-time operation.
  6. Do not publish the maps to your CDN. Ship only what the browser needs.
  7. 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

Subscribe

Get new engineering posts in your inbox

This opens your mail client with a short message to [email protected]. Prefer no email at all? Use the RSS feed.