All posts
Engineering

How source maps fall short on function names

A source map recovers the exact file, line and column of a minified stack frame — but not the function name. Here is why, decoded by hand, and the fix.

A JavaScript source map can give you the original file, line and column of a minified stack frame, every time, exactly. It cannot give you the original function name — not reliably, and not because the tooling is immature. The format stores points, never ranges, so the question "which function encloses this column?" has no field in the file that could hold the answer. That is why every serious error tracker, TracePath included, asks you to upload the minified bundle next to the map.

Below we work the whole thing by hand on a tiny real program: decode the mappings field digit by digit, do the position lookups (they are excellent), watch the names fall apart (spectacularly), and then fix them with the one extra step that needs the bundle. Everything here is real output, produced by running the code on the programs described, with esbuild 0.25 and node v24.

Key takeaways

  • A source map is a list of point mappings, one per renamed token. It has no concept of a scope, a range or nesting.
  • Position resolution from the map alone is flawless. Name resolution from the map alone is wrong on every frame, in three distinct ways.
  • The "steal the name from the frame below" heuristic works only for direct, same-file, still-on-the-stack calls. Callbacks and async resumptions break it silently — the worst failure mode for a tool you reach for mid-incident.
  • The fix is three steps per frame: location from the map, enclosure from the parsed bundle, name from the map again — asked at the enclosing function's name token column, not its first column.
  • node --enable-source-maps looks like a counterexample and is not: V8 has already parsed the bundle, because it is the thing executing it.

1. The program and the crash

The smallest program that throws across more than one stack frame is two files.

src/user.ts
export interface User {
  name: string;
}
 
export function validateUser(user: User): User {
  const trimmed = user.name.trim();
  if (trimmed.length === 0) {
    throw new Error("user has no name");   // line 8
  }
  return { name: trimmed };
}
src/index.ts
import { validateUser, type User } from "./user";
 
function handleSignup(form: User): User {
  return validateUser({ name: form.name });   // line 4
}
 
handleSignup({ name: "   " });                 // line 7

Bundled and minified with esbuild src/index.ts --bundle --format=iife --minify --sourcemap --outfile=dist/app.min.js, the whole program collapses onto one line. Look at what the minifier did to the names. This is the crime scene: validateUser became n, handleSignup became t, and both parameters became e:

(()=>{function n(e){let r=e.name.trim();if(r.length===0)throw new Error("user has no name");return{name:r}}function t(e){return n({name:e.name})}t({name:"   "});})();
//# sourceMappingURL=app.min.js.map

Run it, and it crashes. Here is the raw stack trace — exactly what an error tracker receives from a browser in production:

Error: user has no name
    at n (app.min.js:1:63)
    at t (app.min.js:1:129)
    at app.min.js:1:146
    at app.min.js:1:164

Four frames. Each one is a line:column into the bundle, plus a minified name when V8 has one; that bottom frame is the top-level call of the IIFE wrapper itself, the () at the very end of the line. That is the entire amount of information available. (The columns are 1-based, V8's convention. File that away — the off-by-one bites later.)

What a great symbolicator produces is this:

Error: user has no name
    at validateUser (src/user.ts:8:11)
    at handleSignup (src/index.ts:4:10)
    at <global> (src/index.ts:7:1)
    at <global> (src/index.ts:7:29)

Names back, real .ts files, real lines. Let's earn it.

2. Anatomy of the source map

The .map file is just JSON. Here is the real one, formatted, with sourcesContent truncated:

{
  "version": 3,
  "sources": ["../src/user.ts", "../src/index.ts"],
  "sourcesContent": ["export interface User {\n  name: string;\n}\n...", "import { validateUser..."],
  "mappings": "MAIO,SAASA,EAAaC,EAAkB,CAC7C,IAAMC,EAAUD,EAAK,KAAK,KAAK,EAC/B,GAAIC,...",
  "names": ["validateUser", "user", "trimmed", "handleSignup", "form", "validateUser"]
}

Five fields matter:

  • version: always 3 today.
  • sources: the original files, referenced later by index. Index 0 is ../src/user.ts, index 1 is ../src/index.ts. (The ../ is esbuild recording the path relative to dist/; a symbolicator normalizes it back to src/user.ts.)
  • sourcesContent: the full original text of each source. Optional, but almost always present. This is why DevTools can show your real code without the .ts files being on the server.
  • names: a flat pool of original identifier strings, referenced by index. First surprise: "validateUser" appears twice, at index 0 and index 5. No deduplication. And, crucially, there is no "minified to original" dictionary anywhere in the file. The array is purely positional.
  • mappings: the heart of it. A compressed list of point mappings, one per interesting token: "the token at this bundle column came from this source, this original line, this original column, and (optionally) was spelled this name."

That is the load-bearing fact for everything below: a source map is a list of points, not ranges. It records "column X maps to original Y." It never records "columns X through Z are the body of function F." That single design decision is why the bundle becomes necessary.

3. Decoding mappings by hand: the VLQ format

The mappings string looks like a cat walked across the keyboard. It is not. It is three layers of structure stacked on each other:

  1. Semicolons (;) separate generated lines. This bundle is one line, so the map has zero semicolons. It is all one group. (In a normal multi-line bundle, each ; bumps you to the next output line.)

  2. Commas (,) separate segments within a line. One segment = one token.

  3. Each segment is 1, 4, or 5 numbers, Base64-VLQ encoded:

    FieldsMeaning of each field (every one is a delta from the previous segment)
    1[generatedColumn]
    4[generatedColumn, sourceIndex, originalLine, originalColumn]
    5[generatedColumn, sourceIndex, originalLine, originalColumn, nameIndex]

Everything is a delta, never an absolute. That is the trick that keeps the string short: values accumulate left to right. One gotcha worth nailing down: generatedColumn resets to 0 at every new line (every ;), but sourceIndex, originalLine, originalColumn and nameIndex keep accumulating across the entire file.

Base64-VLQ in one paragraph

Each number is one or more Base64 digits. Take the alphabet ABCD…abcd…0123…+/ (A=0, B=1, … /=63). For each character, grab its 6-bit value. The top bit (value 32) is a continuation flag: set means another digit follows carrying higher-order bits. The low 5 bits are payload, shifted 5 per continuation. Once you have the full integer, the lowest bit is the sign (1 = negative) and the rest is magnitude: value = (n & 1) ? -(n >>> 1) : (n >>> 1).

Here is the whole decoder, no dependencies (deliberately naive, not optimal):

const ALPHA = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
 
function decodeSegment(seg) {
  const vals = [];
  let shift = 0, cur = 0;
  for (const c of seg) {
    const d = ALPHA.indexOf(c);
    cur |= (d & 31) << shift;      // low 5 bits are payload
    if (d & 32) {                  // bit 32 = "continuation, more digits follow"
      shift += 5;
    } else {
      vals.push(cur & 1 ? -(cur >>> 1) : cur >>> 1);  // low bit = sign
      cur = 0; shift = 0;
    }
  }
  return vals;
}

One segment, all the way down

Take the second segment of the map, SAASA, character by character.

CharBase64 valueBinary (6 bit)Continuation?Payload (low 5)
S18010010no (bit 32 clear)10010 = 18
A0000000no0
A0000000no0
S18010010no18
A0000000no0

No continuation bits, so each character is its own number. Apply the sign rule (value = n&1 ? -(n>>1) : n>>1):

  • 18 → 18 >>> 1 = 9
  • 0 → 0
  • 0 → 0
  • 18 → 9
  • 0 → 0

So SAASA decodes to [9, 0, 0, 9, 0]. Five fields, so it carries a name. Apply the deltas to the running totals (after the first segment MAIO, those stood at generatedColumn 6, sourceIndex 0, originalLine 4, originalColumn 7, nameIndex 0):

  • generatedColumn: 6 + 9 = 15
  • sourceIndex: 0 + 0 = 0 → ../src/user.ts
  • originalLine: 4 + 0 = 4
  • originalColumn: 7 + 9 = 16
  • nameIndex: 0 + 0 = 0 → names[0] = "validateUser"

Out loud: bundle column 15 maps to user.ts line 4, column 16 (0-based), originally named validateUser. Bundle column 15 is the n in function n(, the minified validateUser. The map recorded the rename at the exact column where the token physically sits. Not near it. At it. That is the whole trap.

Continuation example

kBAAkB contains a multi-digit number. k = 36 = 100100: bit 32 is set, so it continues. Payload 00100 = 4. Next character B = 1 = 000001, no continuation, payload 00001 = 1. Combine: 4 | (1 << 5) = 36. Sign rule: 36 >>> 1 = 18. So kB is one number, 18, and kBAAkB is [18, 0, 0, 18] — four fields, no name.

The full decode

Doing that for every segment gives the complete mapping table. Here are the rows that matter, in the map's native 0-based line/column, with the bundle text at each column so you can see precisely which token each row pins:

bundle col -> original file:line:col      name          | bundle text at that col
         6 -> ../src/user.ts:4:7                         | "function n(e){le"
        15 -> ../src/user.ts:4:16      validateUser      | "n(e){let r=e.nam"   <- token `n` (the def of validateUser)
        17 -> ../src/user.ts:4:29      user              | "e){let r=e.name."   <- param `e` in n
        24 -> ../src/user.ts:5:8       trimmed           | "r=e.name.trim();"
        62 -> ../src/user.ts:7:10                        | "new Error(\"user "  <- THROW SITE: no name
       107 -> ../src/index.ts:2:0                        | "function t(e){re"
       116 -> ../src/index.ts:2:9      handleSignup      | "t(e){return n({n"   <- token `t` (the def of handleSignup)
       118 -> ../src/index.ts:2:22     form              | "e){return n({nam"   <- param `e` in t
       128 -> ../src/index.ts:3:9      validateUser      | "n({name:e.name})"   <- the CALL n(...) inside t
       145 -> ../src/index.ts:6:0      handleSignup      | "t({name:\"   \"});"  <- the CALL t(...) at top level
       159 -> ../src/index.ts:6:28                       | ");})();"            <- the map's LAST mapping (file this away too)

Two things to stare at, because the entire failure mode lives in them:

  1. The rename n → validateUser is recorded everywhere the token n physically appears — its definition (column 15) and its call site (column 128) — and nowhere else. The throw statement at column 62 contains no n, so the map says nothing about the name there. The field is empty. The map is not being lazy; there was simply no n token to annotate.
  2. The same minified name maps to different originals depending on where it sits. e is user at column 17 and form at column 118. There is no global "minified e → original name" lookup, and there cannot be one, because renames only exist positionally. "Just reverse the minification" is a fantasy.

4. Resolving locations: the floor lookup (this part is great)

For a frame at bundle line:column, how do you find the original location? You rarely get an exact hit — most columns have no mapping at all. So you do a floor lookup: take the mapping with the largest column ≤ the query column. Its source, line and column are the answer.

function floorLookup(rows, genLine, genCol) {
  let best = null;
  for (const r of rows) {
    if (r.genLine !== genLine) continue;
    if (r.genCol > genCol) break;   // rows are sorted by column
    best = r;
  }
  return best;
}

The off-by-one: stack traces are 1-based (V8), source maps are 0-based. Subtract 1 from the frame's line and column before the lookup; add 1 back after, for human eyes.

Frame 1 is at n (app.min.js:1:63). Subtract 1 to get query line 0, column 62. The floor lookup lands on the mapping at column 62 (new Error("user ...), which points at user.ts 0-based line 7, column 10. Add 1 back to get user.ts:8:11 — the throw on line 8. Nailed it.

All four frames:

frame                     | resolved location (works!) | map name at frame position
--------------------------+----------------------------+---------------------------
at n (1:63)               | ../src/user.ts:8:11        | ""
at t (1:129)              | ../src/index.ts:4:10       | "validateUser"
at (no name) (1:146)      | ../src/index.ts:7:1        | "handleSignup"
at (no name) (1:164)      | ../src/index.ts:7:29       | ""

The locations are flawless: the throw on user.ts:8, the call on index.ts:4, the top-level call on index.ts:7, even the wrapper invocation pinned to the tail of line 7. The map absolutely crushes positions.

5. Resolving names with the map alone: a trainwreck

Now look at that right-hand column. The correct names are validateUser, handleSignup, <global>, <global>. The map hands back "", "validateUser", "handleSignup", "". All four wrong — and the way each one is wrong is the interesting part:

  • Frame 1 (the throw site) → "". The tokens at the crash position (throw, new, Error) were never renamed, so the map never recorded a name there. Blank. The function actually executing (validateUser) is defined 47 columns to the left, and the map has no way to know that is the one you want.
  • Frame 2 → "validateUser": the wrong function entirely. This frame is executing inside handleSignup. But a caller frame's saved position is, by definition, the call site, and the token at column 128 is the call n(...) — the callee. So the map returns the name of the function being called, not the one you are in. Every caller frame in every stack trace has this property. It is not a bug in the map; it is the map answering a different question than the one being asked.
  • Frame 3 (top-level code) → "handleSignup". Same trick: the position sits on the call t(...), so you get the callee's name again, for a frame that is not inside any function at all.
  • Frame 4 (the wrapper call) → "". Same failure as frame 1: nothing at that position was ever renamed.

And the minified names from the trace itself (n, t)? Useless. A real bundle has dozens of unrelated ns; the map can only translate a name at a known position, never a name in the abstract.

There is one cute pattern hiding in that table: frame N's correct name tends to sit at frame N+1's position. Frame 1's answer (validateUser) is the map-name on frame 2's row; frame 2's answer (handleSignup) is the map-name on frame 3's row. Shift the names up a row and frames 1 and 2 come out right. Tempting. But frame 3 only inherits frame 4's blank, the bottom frame has no row below it to steal from, and the trick detonates on framework callbacks and async resumptions. This "caller-site fallback" is real, and production systems keep it as a backup, but it is a heuristic wearing a trench coat.

Why not always steal from the frame below?

Because it lies, and catching it lying is what makes the bundle parse feel non-negotiable rather than paranoid. The shift-up trick answers "what name was typed at the call site one frame down." The question a stack frame actually poses is "whose body is executing here." For a boring direct call those two questions share an answer: validateUser(...) minifies to n(...), the map annotates that n token with the real name, done. The program above is accidentally wall-to-wall boring calls. Real codebases are not.

Second tiny program. validateUser stays exactly as it was; around it:

// indirect call: the function arrives through a parameter
function dispatch(handler: (u: User) => User, u: User): User {
  return handler(u);
}
 
// async: after the await, the original call chain is gone
async function handleSignup(form: User): Promise<User> {
  await Promise.resolve();
  return dispatch(validateUser, { name: form.name });
}
 
handleSignup({ name: "   " });

esbuild renames dispatch to s and handleSignup to a (validateUser is n again), and the crash comes back as three frames:

Error: user has no name
    at n (heuristic.min.js:1:63)
    at s (heuristic.min.js:1:131)
    at a (heuristic.min.js:1:187)

Run the shift-up trick on it, next to the truth:

frame            | stolen from the frame below        | truth (enclosure)
-----------------+------------------------------------+------------------
at n (1:63)      | "handler"                          | WRONG: validateUser
at s (1:131)     | "dispatch"                         | dispatch (match)
at a (1:187)     | (no frame below, nothing to steal) | WRONG: handleSignup

Three frames, three different endings:

  • validateUser got named handler — confidently, plausibly, wrong. dispatch invokes it as handler(u), so the token physically sitting at the call site is the parameter handler, and the map translates exactly what is there, as it always does. The caller's call site records what the caller typed, and the caller did not type validateUser. Every callback, dispatch table, middleware chain, cb(), next() and fn.call(...) in a real codebase produces this shape. The cruel detail: "validateUser" is in this map, attached to the argument token n in s(n, ...), two columns to the right of frame 3's position. The heuristic has no way to know that is the token to ask about.
  • dispatch came out right. handleSignup calls it directly, by name. That is the one shape the trick handles.
  • handleSignup got nothing. The await severed it: by the time validateUser throws, the call handleSignup({ ... }) finished long ago and is gone from the stack. The frames below are microtask plumbing in another file. Every async function resumed after an await has this property, which in a modern codebase is most functions.

And this is still one bundle, one file. Let the caller be a framework scheduler, a DOM event dispatch or a setTimeout, and the frame below lives in someone else's bundle (different file, different map) or in native code (no frame at all). Let the trace hit Error.stackTraceLimit and the last visible frame loses its donor entirely.

So the frame below tells you what its author typed to make a call. It does not tell you whose body is executing in your frame. Those coincide for direct, same-file, still-on-the-stack calls and silently diverge everywhere else.

6. Why the map cannot do it

Here is the question that actually needs answering, for every frame:

Which function encloses bundle column 62?

The answer is written down in exactly one place: the brace structure of the bundle text. function n(e){ … } runs from column 6 to 107; function t(e){ … } runs 107 to 145 (spans end-exclusive, the way a parser reports them); column 62 lives inside the first. But the source map format has no notion of a range, a scope or nesting. Only points. So the enclosure question is not hard to answer from the map — it is unanswerable from the map, because the format has no field that could ever hold the answer.

Why is it built this way? Because source maps were designed for debuggers, and a debugger is running the bundle. DevTools always has the full bundle text in hand and parses scopes itself. It only ever needed the map for two things: translate a position, translate a token's name. The consumer that has the map but not the bundle — a server receiving a stack string over HTTP, which is precisely what an error tracker is — was never in the room when the format was designed.

7. The fix: map plus parsed bundle

This is what production symbolicators do, and exactly why they make you upload the bundle next to the map. Three steps per frame:

For frame "at n (app.min.js:1:63)":
 
  STEP 1  LOCATION   (map only)     map.lookup(1:62)  -> user.ts:8:11
  STEP 2  ENCLOSURE  (bundle only)  parse bundle; col 62 sits inside `function n`
                                    whose NAME TOKEN is at col 15
  STEP 3  NAME       (map again)    map.lookup(1:15)  -> "validateUser"

Step 2 is the only one that touches the bundle, and it is the magic move: it converts "where the code crashed" (column 62) into "where the enclosing function's name token lives" (column 15), and column 15 is the one position where the map bothered to record that rename. Location and name both come from the map; the bundle just says which column to ask about.

Parse the bundle once into a compact scope index — sorted function ranges, each tagged with the column of its name token — and from then on each frame is two map lookups and a range check. Here is the whole thing, with acorn doing the parse:

import { readFileSync } from "node:fs";
import * as acorn from "acorn";
import { decodeMappings, floorLookup } from "./vlq.mjs";
 
const map = JSON.parse(readFileSync("dist/app.min.js.map", "utf8"));
const rows = decodeMappings(map);
const bundleLine = readFileSync("dist/app.min.js", "utf8").split("\n")[0];
 
// --- STEP 2 prep: parse the bundle into function scopes ---
const ast = acorn.parse(bundleLine, { ecmaVersion: "latest" });
const scopes = [];
(function walk(node) {
  if (!node || typeof node.type !== "string") return;
  if (node.type === "FunctionDeclaration" || node.type === "FunctionExpression") {
    scopes.push({
      start: node.start,
      end: node.end,
      nameCol: node.id ? node.id.start : null,  // column of the function's NAME token
    });
  }
  // (production also indexes ArrowFunctionExpression, methods, and class fields;
  //  esbuild's IIFE wrapper is an arrow, and skipping it is exactly right here:
  //  code directly inside the wrapper IS the original top-level code)
  for (const k in node) {
    const v = node[k];
    if (Array.isArray(v)) v.forEach(walk);
    else if (v && typeof v.type === "string") walk(v);
  }
})(ast);
scopes.sort((a, b) => (b.start - a.start) || (a.end - b.end));  // innermost first
 
function enclosingNameCol(col) {
  for (const s of scopes) if (col >= s.start && col < s.end) return s.nameCol;
  return null;  // inside no function => global scope
}
 
// --- resolve each frame ---
function symbolicate(line1, col1) {       // 1-based, straight from the trace
  const col0 = col1 - 1;
  const locHit  = floorLookup(rows, line1 - 1, col0);          // STEP 1
  const nameCol = enclosingNameCol(col0);                       // STEP 2
  let name;
  if (nameCol === null) name = "<global>";
  else {
    const nameHit = floorLookup(rows, line1 - 1, nameCol);     // STEP 3
    name = nameHit && nameHit.name ? nameHit.name : "(unknown)";
  }
  const loc = locHit ? `${locHit.source}:${locHit.line + 1}:${locHit.col + 1}` : "(no mapping)";
  return { name, loc };
}

Running it on the four real frames, with the steps printed:

  raw frame:  at n (1:63)
   step1 location  map.lookup(1:62)   -> ../src/user.ts:8:11
   step2 enclosure col 62 is inside function whose name token is at col 15
   step3 name      map.lookup(1:15)   -> "validateUser"
   RESULT:  at validateUser (../src/user.ts:8:11)
 
  raw frame:  at t (1:129)
   step1 location  map.lookup(1:128)  -> ../src/index.ts:4:10
   step2 enclosure col 128 is inside function whose name token is at col 116
   step3 name      map.lookup(1:116)  -> "handleSignup"
   RESULT:  at handleSignup (../src/index.ts:4:10)
 
  raw frame:  at (anon) (1:146)
   step1 location  map.lookup(1:145)  -> ../src/index.ts:7:1
   step2 enclosure col 145 is inside NO function => <global>
   step3 name      (global, no lookup)
   RESULT:  at <global> (../src/index.ts:7:1)
 
  raw frame:  at (anon) (1:164)
   step1 location  map.lookup(1:163)  -> ../src/index.ts:7:29
   step2 enclosure col 163 is inside NO function => <global>
   step3 name      (global, no lookup)
   RESULT:  at <global> (../src/index.ts:7:29)

Assembled:

Error: user has no name
    at validateUser (src/user.ts:8:11)
    at handleSignup (src/index.ts:4:10)
    at <global> (src/index.ts:7:1)
    at <global> (src/index.ts:7:29)

That is the target, exactly. The only thing that pulled the names out of the fire was parsing the bundle.

8. But node --enable-source-maps does it from the map alone — right?

No, and this is the strongest proof in the post. Node has built-in source-map support. Run the minified bundle with the flag and it just resolves the names:

$ node --enable-source-maps dist/app.min.js
Error: user has no name
    at validateUser (src/user.ts:8:11)
    at handleSignup (src/index.ts:4:10)
    at handleSignup (src/index.ts:7:1)      <- WRONG: the global frame wearing its callee's name
    at <anonymous> (src/index.ts:7:29)

From Node's own source (lib/internal/source_map/prepare_stack_trace.js):

function getOriginalSymbolName(sourceMap, callSite, callerCallSite) {
  const enclosingEntry = sourceMap.findEntry(
    callSite.getEnclosingLineNumber() - 1,
    callSite.getEnclosingColumnNumber() - 1,
  );
  if (enclosingEntry.name) return enclosingEntry.name;
  // fallback: use the symbol name from the caller site ...
}

getEnclosingLineNumber() / getEnclosingColumnNumber() are V8 CallSite APIs answering "where is this frame's enclosing function defined?" — which is literally Step 2 above. Node gets it for free because V8 has already parsed the bundle: it is the thing executing it. Then Node queries the map at that definition position (Step 3). The JavaScript engine is quietly playing the role acorn plays above. A server-side symbolicator has no running V8 holding a parsed bundle, which is precisely why it has to parse the bundle itself.

Except — and this only shows up if you instrument the CallSite objects and watch the lookups happen — on this bundle the enclosing lookup comes back empty for every single frame. V8 reports the enclosing function's first character, column 6 for function n, and esbuild recorded the rename nine columns to the right, on the name token at column 15. The mapping sitting at column 6 exists and has no name field. So enclosingEntry.name is blank four times out of four, and every name in that output came from the caller-site fallback — the shift-the-names-up-a-row heuristic from section 5, quietly doing all of the work, in Node core.

Which sharpens Step 2 into its real form: it is not "find the enclosing function", it is "find the enclosing function's name token", because that is the only column where the map wrote the rename down. That is why the walk above records node.id.start and not node.start.

And then look at that third frame: handleSignup where it should be <global>. That frame is the original top-level code (the body of esbuild's IIFE wrapper), there is no named enclosing function anywhere above it, so the fallback fires on the wrapper-call frame below it, and the floor lookup lands on the map's very last mapping — the one flagged earlier. That mapping has no name field either. It gets one anyway: Node's VLQ parser, on the final segment of the entire mappings string, peeks past the end, sees no separator, reads a phantom fifth field of zero, and stamps the segment with whatever the name accumulator was last holding, which is names[3], handleSignup. A callee leak, delivered by a parser quirk, onto a global frame. (The bottom frame at least fails honestly: its caller lives in Node's internals, a different file, so the fallback gives up and prints <anonymous>.)

The by-hand version dodges both for the same two reasons: Step 2 hands back the name token's column instead of the function's first column, and "inside no function" is treated as an explicit <global> rather than a fallback — which matters a lot, because the bottom frames of basically every browser trace are global code.

9. The three-artifact reality

Each artifact answers a different question, and you need all three:

ArtifactWhat it provides
Stack tracethe query: bundle positions and minified names
Source maptranslation: position → original position, and token → original name
Bundlestructure: which function encloses a position, and where its name token sits

Drop the bundle and you keep flawless locations and lose every name to one of three failure modes: blank at the crash token, the callee's name on caller frames, the callee's name on the global frame.

The four traces this one tiny program can produce, side by side:

TraceNamesLocationsWhat it proves
minified (production)n, t, none, nonebundle line 1the starting point a tracker receives
non-minified bundlecorrectbundle positionswhat minification destroyed
node --enable-source-mapsmostly correct, callee leak on globaloriginal TSevery name came from the caller-site fallback
targetcorrect incl. <global>original TSrequires map plus parsed bundle

How TracePath implements this

The three steps above are the algorithm running inside TracePath's symbolication pipeline. Two details are worth calling out, because they are what turns the algorithm into something that survives production traffic:

  • The scope index is precomputed, not rebuilt per frame. On upload, the bundle is parsed once and reduced to a sorted list of scope transitions, each carrying the position of its function's name token. That index is stored next to the map as a compact artifact, so resolving a frame later is two floor lookups and a binary search, not a parse.
  • The parse is pluggable. A pure-Go JavaScript parser is the default, with a Rust-backed parser as a build-time option for very large bundles where parse time dominates the first upload.

The result is what shows up on an issue: real function names, real .ts files, real lines, on stack traces that arrived minified. Plan limits — projects, seats, ingest volume and retention — are on the pricing page.

The fix coming to the format itself

There is light at the end of this. The ECMA-426 source map standard has an in-progress Scopes extension (originalScopes / generatedRanges) that bakes scope ranges and names directly into the map, turning it from a bag of points into something that finally contains the enclosure answer. The day bundlers emit it widely, the bundle upload becomes unnecessary and a whole code path gets deleted with a smile. Until that day arrives across every bundler in the wild, the three-artifact reality stands: want correct function names, ship the bundle with the map.

FAQ

Do I have to upload the minified bundle, or is the map enough? The map alone gives correct file, line and column on every frame. Function names need the bundle. If your traces show right locations and wrong or missing names, a missing bundle is the first thing to check.

Does uploading the bundle expose my source code? The bundle is the minified artifact your users already download from your CDN — it is public by construction. The thing that exposes original source is sourcesContent inside the .map, which is why maps are uploaded to the error tracker rather than served to browsers.

Why do some frames still say <anonymous> after symbolication? Because the function genuinely has no name token to look up: an anonymous arrow assigned to nothing, a callback passed inline. There is no name in the map to recover, and inventing one from the call site is the heuristic this post spent two sections disproving.

Does this apply to Node and server-side JavaScript too? Yes. The algorithm is identical; what changes is who already has the parsed bundle. Inside the running process, V8 does. Anywhere else — a tracker receiving a stack string over HTTP — you parse it yourself.

What about Safari and Firefox traces? Positions and names work the same way, but error.stack has no standard. Chromium, Node and Deno speak V8's dialect (at fn (file:line:col)), Safari and Bun speak JavaScriptCore's (fn@file:line:col), Firefox speaks SpiderMonkey's, and the dialects disagree about async frames, eval frames, anonymous functions and where exactly a column points. Parsing that zoo correctly is its own problem, upstream of everything in this post.

Conclusion

Everything above is step one of a world-class symbolicator, and it is the step with the trap in it: location from the map, enclosure from the bundle, name from the map. The map answers where. The bundle answers who. You need both.

The version here is deliberately minimal. A production implementation adds binary search over both indexes, multi-line bundles, arrow and method scopes, inlined-function handling, and caching — and then has to handle the other half of the problem, which is that the stack trace format itself was never standardized.

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.