How to Decode a Minified JavaScript Stack Trace with a Source Map
On this page
To decode a minified stack trace like at e (app.BhjnrRq7.js:1:68), look up each frame’s line and column in the source map from the same build: the map returns the original file, line, column and sometimes a name. Paste the trace and the .map into a local source map tool, or script the lookup per frame. Below is a real trace from a small sample app decoded frame by frame, plus how to keep the maps private and what to check when the answer looks wrong.
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 (a third-party open-source reverse-engineering CLI; setup guide) has trace-web-source-location. It maps one generated position in a captured script through a local map you select. There is no command that takes a whole stack trace, so you run one lookup per frame.
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. REA’s browser observation docs cover how to start the browser and pick the target ID.
What it returns, from REA’s 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: 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. Electron renderers work the same way once you unpack app.asar to get the bundle and its maps.