A few weeks ago I wrote that minified browser code gives you function names like t and n until you apply source maps, and then admitted I hadn't tested it. This weekend I tested it.
I didn't want another upload walkthrough. I wanted to know one thing: when a frame says a @ app.min.js:1:88, how do I prove the map I have turns it into the right function and the right line? And what does it look like when the map is wrong?
So I built a 15-line checkout module, minified it with esbuild, threw a known error through the official Sentry SDK into a local tap, and applied the map by hand with the source-map package. Then I broke it two ways on purpose.
Versions from run-0-versions.txt: Node v24.22.1, esbuild 0.28.2, source-map 0.8.0, @sentry/node 11.11.0. Everything ran locally on 2026-11-08. No tracker was involved, so this works the same if you run GlitchTip, self-hosted Sentry, or your own ingest.
TL;DR
- With the matching map, the SDK frame
a @ app.min.js:1:88resolved tochargeCardatsrc/checkout.js:5, the exactthrowline. The callertresolved toprocessOrderat line 12. - A stale map gave no error and no warning. It mapped the crash to
return { charged: total };and the caller tomodule.exports = { processOrder };. A map stored under the wrong release wasn't found at all, and the event was stored anyway withoandu. - What fixed it for me is a check: throw a known error from the built bundle, apply the map you're about to upload, and fail if the crash frame isn't
chargeCardon a line containingthrow. Mine printsPASSorFAILand exits 1 on a miss.
The mental model: a map is a position lookup with a two-part key
A source map answers one question: "this line and column in the bundle came from which file, line and column in the source?" Your tracker gets a position from the SDK, finds a map, and asks that question. It can only find the right map if the event's key matches how you stored it.
Here's what each side holds, from my lab files:
| Piece | What it holds in this lab |
|---|---|
| SDK frame | filename, function (a), lineno (1), colno (88) |
.map file | version, sources, sourcesContent, mappings, names |
| Lookup key | the event's release (shop@1.0.0) plus the frame's file name |
Three things have to line up: the bundle that actually ran, the map built from that same bundle, and the key you stored the map under. Any one of them off and you get either minified frames or wrong frames. Wrong frames are the worse case, because they look fine.
Step 1: a bundle with two named functions
The source is two functions so a demangled frame is obvious at a glance:
// src/checkout.js: two named functions so a demangled frame is obvious. function chargeCard(order) { const total = order.items.reduce((sum, item) => sum + item.price * item.qty, 0); if (order.card.balance < total) { throw new Error(`card declined: needed ${total}, balance ${order.card.balance}`); } return { charged: total }; } function processOrder(order) { if (!order.items.length) return { charged: 0 }; return chargeCard(order); } module.exports = { processOrder }; Install pinned and build:
npm i --save-exact esbuild@0.28.2 source-map@0.8.0 @sentry/node@11.11.0 npx esbuild src/checkout.js --bundle --minify --sourcemap --platform=node --outfile=dist/app.min.js esbuild wrote dist/app.min.js (294b) and dist/app.min.js.map (1.0kb). The whole bundle is one line:
function a(e){let r=e.items.reduce((n,c)=>n+c.price*c.qty,0);if(e.card.balance<r)throw new Error(`card declined: needed ${r}, balance ${e.card.balance}`);return{charged:r}}function t(e){return e.items.length?a(e):{charged:0}}module.exports={processOrder:t}; //# sourceMappingURL=app.min.js.map chargeCard became a. processOrder became t. Those are the names your tracker sees without a map.
Step 2: capture the frames the SDK actually sends
Don't trust err.stack for this. Read what the SDK puts in exception.values[0].stacktrace.frames, because that's what your tracker symbolicates.
I pointed Sentry.init at http://devkey@127.0.0.1:9999/1 and ran a tiny tap on that port. It unzips each envelope, keeps event items, and appends the release, the error message and the frames to a JSONL file:
// tap.js: fake DSN host. Saves each error event's frames to events.jsonl. const http = require('http'), zlib = require('zlib'), fs = require('fs'); http.createServer((req, res) => { const chunks = []; req.on('data', c => chunks.push(c)); req.on('end', () => { let buf = Buffer.concat(chunks); if (req.headers['content-encoding'] === 'gzip') buf = zlib.gunzipSync(buf); const lines = buf.toString().split('\n'); for (let i = 1; i < lines.length && lines[i]; i += 2) { if (JSON.parse(lines[i]).type !== 'event') continue; const ev = JSON.parse(lines[i + 1]); fs.appendFileSync(process.env.OUT || 'events.jsonl', JSON.stringify({ release: ev.release, value: ev.exception.values[0].value, frames: ev.exception.values[0].stacktrace.frames, }) + '\n'); } res.end('{}'); }); }).listen(9999, '127.0.0.1', () => console.log('tap on :9999')); run.js inits the SDK with release: 'shop@1.0.0', requires the bundle, calls processOrder with a card balance of 50 and an 80 total, and calls Sentry.captureException in the catch. The in-app frames the SDK sent (run-3-sdk-frames.txt):
release: shop@1.0.0 {"filename":"dist/app.min.js","function":"a","lineno":1,"colno":88} {"filename":"dist/app.min.js","function":"t","lineno":1,"colno":209} {"filename":"run.js","function":"Object.?","lineno":11,"colno":3} That's the "before" picture. Message card declined: needed 80, balance 50, function a, line 1.
Step 3: apply the map and print the original line
Applying a map is originalPositionFor with the frame's line and column. Printing the source line next to the result is what turns "it returned something" into proof you can eyeball.
The core of symbolicate.js:
const map = await new SourceMapConsumer(raw); for (const f of ev.frames.filter(f => f.in_app && f.filename.endsWith('.min.js')).reverse()) { // SDK colno is 1-based, source-map wants 0-based columns const pos = map.originalPositionFor({ line: f.lineno, column: f.colno - 1 }); const src = pos.source ? map.sourceContentFor(pos.source, true) : null; const text = src && pos.line ? src.split('\n')[pos.line - 1].trim() : '(no source)'; // pos.name is the token at the crash column, not the enclosing function. // For the function, map the token right after "function <minName>(" in the bundle. const at = minLines[f.lineno - 1].indexOf(`function ${f.function}(`); const fn = at < 0 ? null : map.originalPositionFor({ line: f.lineno, column: at + 9 }).name; console.log(`${f.function} @ app.min.js:${f.lineno}:${f.colno}`); console.log(` -> ${pos.source}:${pos.line}:${pos.column} token=${pos.name} function=${fn}`); console.log(` ${text}`); } Output with the matching map (run-4-demangle.txt):
release shop@1.0.0, map dist/app.min.js.map a @ app.min.js:1:88 -> ../src/checkout.js:5:10 token=null function=chargeCard throw new Error(`card declined: needed ${total}, balance ${order.card.balance}`); t @ app.min.js:1:209 -> ../src/checkout.js:12:9 token=chargeCard function=processOrder return chargeCard(order); That's the "after" picture, and it's right. The crash frame lands on the throw. The caller lands on return chargeCard(order); inside processOrder.
Look at the token= column, though. For frame t, the map's name at that position is chargeCard, because the column points at the call to chargeCard. If your symbolicator labels frames with pos.name, it calls the processOrder frame chargeCard. That's why my script does a second lookup on the function t( declaration to get the enclosing name. The fn lookup is a lab shortcut that only works for function x( declarations. Real symbolicators work out the enclosing scope properly, so it's worth checking that yours does.
Step 4: break it with a stale map
A stale map doesn't fail. It returns a confident answer for the wrong code.
I made v1.0.1: added an applyCoupon function above chargeCard and used it in the total. esbuild renamed everything. applyCoupon is now a, chargeCard is o, processOrder is u. The SDK sent o @ app.min.js:1:156 and u @ app.min.js:1:277 with release: shop@1.0.1.
Then I did what happens when a deploy ships the new bundle but the upload step reuses an old artifact. I applied the v1.0.0 map to the v1.0.1 frames (run-7-stale-map.txt):
release shop@1.0.1, map dist/app.min.js.map o @ app.min.js:1:156 -> ../src/checkout.js:7:2 token=null function=null return { charged: total }; u @ app.min.js:1:277 -> ../src/checkout.js:15:32 token=null function=null module.exports = { processOrder }; No exception from source-map. No warning. The crash "happened" on a return statement, and the caller "is" the module.exports line. If your tracker shows only file and line, this looks like a real stack. You'd go read line 7 of checkout.js and find nothing wrong there.
With the right v1.0.1 map, the same frames resolve to ../src-v2/checkout.js:11:10 (the throw, now 6 lines lower) and processOrder at line 18 (run-7b-right-map.txt).
Step 5: miss the release key
A map stored under the wrong release isn't used at all, and the event still goes in.
resolve.js builds the key the way a tracker keys artifacts: artifacts/<release>/<file>.map. I only stored the v1.0.0 map. Output (run-8-release-lookup.txt):
a @ app.min.js:1:88 key=artifacts/shop@1.0.0/app.min.js.map FOUND t @ app.min.js:1:209 key=artifacts/shop@1.0.0/app.min.js.map FOUND event stored anyway: "card declined: needed 80, balance 50" (10 frames) o @ app.min.js:1:156 key=artifacts/shop@1.0.1/app.min.js.map MISSING, frame stays minified u @ app.min.js:1:277 key=artifacts/shop@1.0.1/app.min.js.map MISSING, frame stays minified event stored anyway: "card declined: needed 80, balance 50" (10 frames) This one at least looks broken. You see o and u and know the map is missing. Honestly I'd take this failure over the stale map every time.
Turn it into a check you run before upload
Throw a known error from the bundle you're about to deploy, symbolicate it with the map you're about to upload, and assert both the function and the line. With EXPECT=chargeCard set, symbolicate.js checks that the crash frame resolves to that function and that the source line contains throw (run-11-verify.txt):
PASS crash frame is chargeCard at a throw exit 0 ... FAIL expected chargeCard at a throw, got null at "return { charged: total };" exit 1 In a real app you don't throw from checkout in CI. Add a tiny canary export (a function that throws a fixed message) to the bundle, call it from a script after the build, and point the check at that. If the deploy job fails on exit 1, a stale map never reaches your tracker. The whole lab is in run-all.sh, a 51-line script of shell plus four small JS files (tap.js, run.js, symbolicate.js, resolve.js).
What about node --enable-source-maps?
For Node you can skip uploads and let Node apply the map at throw time. I tried it. It's good, but it isn't complete.
With node --enable-source-maps, the SDK sent this (run-10-sdk-enable-source-maps.txt):
{"filename":"src/checkout.js","function":"chargeCard","lineno":5,"colno":11} {"filename":"src/checkout.js","function":"t","lineno":12,"colno":10} File and line are right for both frames. The function name is right for the crash frame and still t for the caller. Plain err.stack printed at Object.t (.../src/checkout.js:12:10) too. Also note 5:11 here versus 5:10 from source-map. Same spot, one is 1-based and one is 0-based.
Browsers don't have this flag, so frontend bundles still need the upload path.
Failure checklist
- Stale map. New bundle, old map. You get plausible wrong lines with no error (
return { charged: total };in my run). Only a known-throw check catches it. - Release string mismatch. The event says
shop@1.0.1, the map is stored undershop@1.0.0. Frames stayoandu, and the event is stored anyway. Use the exact same string inSentry.initand in the upload command. - File name mismatch. The file name is the other half of the key. My
resolve.jsuses the basename; real trackers match on the frame's URL or path, often with a~/prefix for "any host". I didn't run this case separately, but inresolve.jsit fails the same way as the release miss. - Token name used as function name.
pos.nameat frametwaschargeCard, the callee. A symbolicator that trusts it mislabels the caller frame. - Column base. SDK frames and Node stacks are 1-based (
5:11).source-mapcolumns are 0-based (5:10). In my bundle, forgetting the- 1landed on the same mapping (run-5-no-col-fix.txtis identical torun-4), so a check like mine wouldn't catch it here. It bites when the crash column sits right at a segment boundary. - Map served publicly with your source. esbuild puts
sourcesContentin the map by default, so a.mapnext toapp.min.json your CDN ships your original code. With--sources-content=falsethe map dropped from 1024 to 493 bytes and still resolvedchargeCardandprocessOrder, but printed(no source). That also means thethrowline check needs the source from your repo instead. -
--enable-source-mapsis partial. It fixed file and line but left the caller ast.
Where this fits for me
I'm building Epure, a small self-hosted error tracker that matches maps by release and file name, so steps 4 and 5 are exactly the failures I care about. The source maps guide has the upload call and its limits. Nothing above depends on it. The check runs before any tracker sees the map.
Takeaway
A successful map upload only tells you a file arrived. A known throw that resolves to chargeCard on a throw line tells you it's the right file for the code that's running. That's one extra CI step, and it turns the stale map from a silent wrong answer into a failed deploy.

