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
asyncresumptions 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-mapslooks 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.
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 };
}import { validateUser, type User } from "./user";
function handleSignup(form: User): User {
return validateUser({ name: form.name }); // line 4
}
handleSignup({ name: " " }); // line 7Bundled 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.mapRun 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:164Four 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: always3today.sources: the original files, referenced later by index. Index0is../src/user.ts, index1is../src/index.ts. (The../is esbuild recording the path relative todist/; a symbolicator normalizes it back tosrc/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.tsfiles 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:
-
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.) -
Commas (
,) separate segments within a line. One segment = one token. -
Each segment is 1, 4, or 5 numbers, Base64-VLQ encoded:
Fields Meaning 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.
| Char | Base64 value | Binary (6 bit) | Continuation? | Payload (low 5) |
|---|---|---|---|---|
S | 18 | 010010 | no (bit 32 clear) | 10010 = 18 |
A | 0 | 000000 | no | 0 |
A | 0 | 000000 | no | 0 |
S | 18 | 010010 | no | 18 |
A | 0 | 000000 | no | 0 |
No continuation bits, so each character is its own number. Apply the sign rule
(value = n&1 ? -(n>>1) : n>>1):
18→18 >>> 1= 90→ 00→ 018→ 90→ 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:
- The rename
n→validateUseris recorded everywhere the tokennphysically appears — its definition (column 15) and its call site (column 128) — and nowhere else. The throw statement at column 62 contains non, so the map says nothing about the name there. The field is empty. The map is not being lazy; there was simply nontoken to annotate. - The same minified name maps to different originals depending on where it
sits.
eisuserat column 17 andformat column 118. There is no global "minifiede→ 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 insidehandleSignup. But a caller frame's saved position is, by definition, the call site, and the token at column 128 is the calln(...)— 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 callt(...), 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: handleSignupThree frames, three different endings:
validateUsergot namedhandler— confidently, plausibly, wrong.dispatchinvokes it ashandler(u), so the token physically sitting at the call site is the parameterhandler, 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 typevalidateUser. Every callback, dispatch table, middleware chain,cb(),next()andfn.call(...)in a real codebase produces this shape. The cruel detail:"validateUser"is in this map, attached to the argument tokennins(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.dispatchcame out right.handleSignupcalls it directly, by name. That is the one shape the trick handles.handleSignupgot nothing. Theawaitsevered it: by the timevalidateUserthrows, the callhandleSignup({ ... })finished long ago and is gone from the stack. The frames below are microtask plumbing in another file. Everyasyncfunction resumed after anawaithas 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:
| Artifact | What it provides |
|---|---|
| Stack trace | the query: bundle positions and minified names |
| Source map | translation: position → original position, and token → original name |
| Bundle | structure: 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:
| Trace | Names | Locations | What it proves |
|---|---|---|---|
| minified (production) | n, t, none, none | bundle line 1 | the starting point a tracker receives |
| non-minified bundle | correct | bundle positions | what minification destroyed |
node --enable-source-maps | mostly correct, callee leak on global | original TS | every name came from the caller-site fallback |
| target | correct incl. <global> | original TS | requires 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.