Decode a Minified JavaScript Stack Trace with a Source Map
On this page
A production error report usually looks like at e (app.BhjnrRq7.js:1:68). The function is called e, the whole bundle is on line 1, and column 68 means nothing to you. The source map from the same build knows where that position came from. This guide shows how to keep such maps without publishing them, how to map every frame of a pasted stack trace back to your own code in the browser, and how to do the same position lookup with REA from a script or an agent.
Who this is for: developers who debug their own production front-end or Node.js code and want readable stack traces without sending maps to a third party.
What you will do:
- Understand why a minified trace points to line 1
- Build with hidden source maps that you keep private
- Decode a real stack trace frame by frame in a local tool
- Handle maps that do not match the bundle
- Run the same lookup with REA’s
trace-web-source-location
Everything here uses a small sample app built for this guide. Apply it to code you own or are authorised to debug.
Why the trace says line 1, column 68
A minifier joins your modules into a few long lines and renames local functions to single letters. The engine reports positions in the code it actually ran, so the stack trace shows the bundle’s coordinates: file, line and column, counted from 1. A source map is a table from those generated positions to original file:line:column positions, plus an optional original name for each position. Decoding a stack trace is a lookup in that table for each frame.
Two details decide whether the lookup is right:
- Same build. The map must come from the exact build that threw. A map from yesterday’s deploy maps the same column to a different place.
- Columns. Stack traces count columns from 1. The source map format counts them from 0. Tools that take stack lines handle this for you; APIs that take raw positions usually want the 0-based column.
Step 1: Build with hidden source maps
The sample is a two-file TypeScript app. src/price.ts validates a line item:
export function lineTotal(item: LineItem): number {
if (!Number.isInteger(item.quantity) || item.quantity < 1) {
throw new RangeError(`Invalid quantity for ${item.sku}: ${item.quantity}`);
}
return item.unitCents * item.quantity;
}
src/main.ts sums a cart with items.reduce((sum, item) => sum + lineTotal(item), 0) and formats it in formatCart. Build it with Vite and hidden maps:
// vite.config.js
export default {
build: { minify: true, sourcemap: 'hidden' },
};
Why hidden: Vite still writes assets/app.BhjnrRq7.js.map, but the bundle has no sourceMappingURL comment, so browsers and visitors are not pointed to it. Store the map with the build’s version or commit, keep it out of the deployed directory, and you can decode errors later without publishing your source. In webpack the same setting is devtool: 'hidden-source-map'; in esbuild it is --sourcemap=external. Audit your npm package for source map leaks explains why the map should not be deployed.
The real minified output is one line:
function e(e){if(!Number.isInteger(e.quantity)||e.quantity<1)throw RangeError(`Invalid quantity for ${e.sku}: ${e.quantity}`);return e.unitCents*e.quantity}function t(t){return t.reduce((t,n)=>t+e(n),0)}function n(e){return`$${(t(e)/100).toFixed(2)}`}export{t as cartTotal,n as formatCart};
Calling formatCart with a quantity of 0 in Node.js 22 produced this stack trace (directory shortened to /work):
RangeError: Invalid quantity for TEE-07: 0
at e (file:///work/price-app/dist/assets/app.BhjnrRq7.js:1:68)
at file:///work/price-app/dist/assets/app.BhjnrRq7.js:1:196
at Array.reduce (<anonymous>)
at t (file:///work/price-app/dist/assets/app.BhjnrRq7.js:1:180)
at n (file:///work/price-app/dist/assets/app.BhjnrRq7.js:1:229)
at file:///work/price-app/run.mjs:6:3
A browser trace looks the same, with https:// URLs instead of file://.
Step 2: Decode the trace in your browser
Open the JS bundle and source map restorer. Drop in the .map file, and the bundle too if you have it, then paste the whole trace into the lookup box. The tool parses Chrome and Node lines (at fn (url:line:col)) as well as Firefox and Safari lines (fn@url:line:col), and plain line:column pairs. Parsing, decoding and lookup run in a Web Worker on your machine. The map is never uploaded, which matters because it contains your source.
This is what it returned for the trace above:
| Frame in the trace | Original position | Name recorded at that position |
|---|---|---|
at e (…app.BhjnrRq7.js:1:68) |
src/price.ts:9:15 |
none |
at …app.BhjnrRq7.js:1:196 |
src/main.ts:4:44 |
lineTotal |
at t (…app.BhjnrRq7.js:1:180) |
src/main.ts:4:16 |
none |
at n (…app.BhjnrRq7.js:1:229) |
src/main.ts:8:17 |
cartTotal |
at …run.mjs:6:3 |
marked other file |
Each result also shows a few lines of original code with the target line marked. For the first frame, that is throw new RangeError(...) in lineTotal. With the bundle loaded, it shows the generated code around column 68 as well. The Array.reduce (<anonymous>) line has no position, so it is skipped. The run.mjs frame names a file other than the bundle, so it is not mapped against this map.
How to read the names. The name column is the identifier the map recorded at that exact position, not the name of the function the frame is in. Frame 2 points at the call e(n), so the recorded name is the callee, lineTotal. Frame 4 points at the call to t(e), recorded as cartTotal. To find the enclosing function, read the snippet: frame 1 is inside lineTotal, frames 2 and 3 are inside cartTotal, and frame 4 is inside formatCart.
How the lookup works. For each position, the tool takes the greatest mapped position at or before it, across lines, and returns every segment found there. This is the same rule REA uses, which the next section relies on. If no mapping exists at or before the position, the frame is reported as unmapped; if the mapping there has no original position, it is reported as generated-only. Neither case is guessed.
Step 3: When the map does not match
Most wrong answers come from pairing the wrong files. Check these in order:
- File hash. Content-hashed names such as
app.BhjnrRq7.jschange with every build. The trace’s file name and the map’sfilefield (shown in the tool’s report) should match. - Line count. The tool’s report lists the bundle’s line count and the map’s generated line count. If the trace mentions line 3 of a one-line bundle, the files do not belong together.
- Beautified copies. Positions copied from a pretty-printed bundle in DevTools do not match the original file. Use the positions from the error report itself.
- Renamed bundles. If no frame names the uploaded bundle, for example after a CDN rewrote the file name, the tool maps every frame against the map. Check the snippets to confirm they make sense.
- No content. A map built without
sourcesContentstill gives you file, line and column, but no snippet. Open the file at that line in your checkout of the same commit.
Step 4: Automate the lookup with REA
The browser tool is the fastest way to decode one pasted trace. For a scripted or agent-driven workflow, REA, the open-source project by morluto, has trace-web-source-location. It maps one generated position in a captured script through a local map you select. REA 6.4.0 has no command that takes a whole stack trace, so you run one lookup per frame. Install REA as in the setup guide.
The command works on scripts REA captured from a page, so the trace and the map are tied to exact bytes. For your own site or a local preview:
rea inspect-web-page http://127.0.0.1:9222 TARGET_ID \
--include-script-sources --json > capture.json
rea export-web-scripts /absolute/path/capture.json /absolute/path/exported --json
rea trace-web-source-location \
/absolute/path/exported/manifest.json 0 \
/absolute/path/maps/app.BhjnrRq7.js.map \
'https://your-site.example/assets/app.BhjnrRq7.js.map' \
1 67 --json
The six arguments are the manifest, the zero-based script index in that manifest, the local map path, a URL used only to resolve the map’s sources (nothing is fetched), a one-based line and a zero-based UTF-16 column. That is why the trace’s 1:68 becomes 1 67. Trace website API calls covers how to start the browser and pick the target ID.
What 6.4.0 returns, from its output schema:
lookup.matched_generated_positionand every equal-position match inmatches. A match isgenerated-only, ormappedwithreported_source,resolved_url,original_positionandname.content.state:embedded, with the original text, when the map hassourcesContent, orunavailablewhen it does not.original_position.content_positionsays whether the position was verified inside that embedded text.source_map.association: "caller-selected",source_authenticity: "unknown"andexecution: "unknown". REA verifies the bytes you gave it, but it does not claim that this map was deployed with that script or that the line ran.
Limits in 6.4.0: maps up to 4 MiB, a 20-second decode deadline, and regular or inline indexed maps only; index maps whose sections point to other URLs are not supported. Over MCP the same operation is trace_web_source_location, so an agent can loop over the frames of a trace and cite each result.
Wire it into your error reporting
The pattern that keeps maps private is the same whichever tool you use:
- Build with hidden maps, and name the build with a version or commit hash.
- Copy the
.mapfiles to private storage, or to an error tracker that accepts uploads, keyed by that version. - Deploy only the
.jsfiles. - When an error comes in, fetch the map for its version and decode the trace with the local tool or REA.
For Node.js services that you run yourself, node --enable-source-maps decodes traces at runtime when the bundle’s sourceMappingURL points to a readable map. With hidden maps there is no comment to follow, so decode the trace offline as shown above.
FAQ
Do I need to upload my source map anywhere?
No. The browser tool decodes it locally, and REA reads it from disk. Upload maps only to a service you have chosen to trust with your source.
Why is the original function name missing?
The map records names only at some positions, usually identifiers. The frame’s position may be a throw, an operator or a call. Use the snippet to see the enclosing function.
Can I decode a trace from a site I don’t own?
Only when you are authorised to debug it and have its map. Do not download maps from sites you are not allowed to analyse.
Next steps
- Keep maps out of packages and deployments: /en/audit-source-map-leaks-before-publish/
- Read a whole bundle with REA’s application graph: /en/recover-javascript-source-from-bundle/
- Decode traces from an Electron app’s renderer: /en/unpack-asar-file/
- The tool used above: JS bundle and source map restorer