A minified stack frame like app.js:1:43892 means nothing by itself — but the source map your bundler produced alongside that file can turn it back into applyDiscount.ts:8, complete with the original function name. To unminify a JavaScript stack trace you have three options: deploy the .map files publicly and let browser DevTools resolve frames, decode frames yourself with Mozilla's source-map library, or — the production standard — build hidden source maps and upload them to an error tracker that rewrites stack traces server-side. This guide covers how source maps actually work, how to generate them in Vite, webpack, esbuild, and tsup, and the failure modes that leave you staring at minified frames anyway.
How do source maps work?
A source map is a JSON file that maps every position in generated code (bundled, minified, transpiled) back to a position in the original source. Browsers and tools find it one of two ways: a comment on the last line of the generated file —
//# sourceMappingURL=app.js.map
— or a SourceMap HTTP header on the response that served the file. The format spent a decade as the de facto "v3" spec before Ecma standardized it as ECMA-426 in December 2024.
Here is a complete, real source map — esbuild minifying one small TypeScript file:
{
"version": 3,
"sources": ["../src/greet.ts"],
"sourcesContent": ["export function greet(name: string): string {\n return \"Hello, \" + name.toUpperCase() + \"!\";\n}\n"],
"mappings": "AAAO,gBAAS,MAAMA,EAAsB,CAC1C,MAAO,UAAYA,EAAK,YAAY,EAAI,GAC1C",
"names": ["name"]
}
Four fields do the work:
sources— the original files, as paths relative to the map.sourcesContent— the original source of each file, embedded verbatim. Optional, but without it a consumer can resolve positions and not show code.names— original identifiers. Minifiers renameapplyDiscounttor; this array is how that gets reversed.mappings— the position data, encoded as base64 VLQs.
mappings looks like line noise, but the structure is regular: semicolons separate generated lines, commas separate segments within a line, and each segment is up to five VLQ-encoded numbers — generated column, source file index, original line, original column, and an optional index into names. Every value is a delta from the previous segment, which keeps the encoding compact even for multi-megabyte bundles. You never decode it by hand; that's what libraries are for (below).
How to generate source maps in Vite, webpack, and esbuild
Every mainstream bundler emits production source maps in two flavors: linked (the .map file plus a sourceMappingURL comment pointing at it) and hidden (the .map file with no comment, so browsers never know it exists). Hidden is what you want when maps go to an error tracker instead of the public.
| Build tool | Linked (.map + comment) | Hidden (.map, no comment) |
|---|---|---|
| Vite | build.sourcemap: true | build.sourcemap: 'hidden' |
| webpack | devtool: 'source-map' | devtool: 'hidden-source-map' |
| esbuild | --sourcemap | --sourcemap=external |
| tsup | --sourcemap | not built in — strip the comment post-build |
Vite
Vite's build.sourcemap defaults to false — production builds emit no maps until you opt in:
// vite.config.ts
import { defineConfig } from "vite";
export default defineConfig({
build: {
// true -> emits .map files and links them with a comment
// "hidden" -> emits .map files, suppresses the comment
sourcemap: "hidden",
},
});
There's also 'inline', which embeds the whole map into the bundle as a data URI. Never ship that to production: it bloats the bundle and hands your original source to every visitor.
webpack
webpack's devtool option has two dozen values; only two matter in production. source-map emits a full map plus the reference comment. hidden-source-map is, per the docs, the "same as source-map, but doesn't add a reference comment to the bundle" — recommended "if you only want SourceMaps to map error stack traces from error reports."
// webpack.config.js
module.exports = {
mode: "production",
devtool: "hidden-source-map",
};
The eval-* and cheap-* variants exist for dev rebuild speed — cheap drops column mappings entirely, which makes a 1:43892 frame undecodable. Don't use them for production artifacts.
esbuild and tsup
esbuild has four sourcemap modes: linked, external, inline, and both. external is the hidden one — esbuild still writes the map, but "the .js output file does not contain a //# sourceMappingURL= comment."
# linked: emits dist/app.js + dist/app.js.map + the comment
esbuild src/main.ts --bundle --minify --sourcemap --outfile=dist/app.js
# hidden equivalent: map emitted, comment suppressed
esbuild src/main.ts --bundle --minify --sourcemap=external --outfile=dist/app.js
tsup, an esbuild wrapper, documents --sourcemap (linked) and --sourcemap inline. There's no hidden mode, so delete the comment in a post-build step if you need one:
tsup src/index.ts --sourcemap # emits dist/index.js and dist/index.js.map
Should you serve source maps publicly?
For closed-source apps: no. A public .map file is your original source — sourcesContent embeds every input file verbatim, comments and all, and one curl reconstructs your repo's client code. The webpack docs are blunt about using source-map in production: "You should configure your server to disallow access to the Source Map file for normal users!" The standard setup is hidden maps plus upload: bundles carry no comment, .map files never reach the CDN, and symbolication happens server-side where the maps live.
The honest counterpoints:
- If your frontend is open source, serve the maps. The source is already on GitHub; linked maps cost you nothing but bandwidth, and DevTools debugging works for everyone. Simplest thing that works.
- Gating maps behind a VPN or auth is doable, but it's extra infrastructure that mostly re-implements what hidden-maps-plus-upload gives you for free.
- Minification is not security. It deters casual reading, nothing more. If your bundle contains an actual secret, the secret is the problem — it already shipped, map or no map.
How to unminify a stack trace by hand
For one bad frame, Mozilla's source-map library — the engine behind Firefox's DevTools — decodes it in about fifteen lines of Node. Say production logged this:
Error: Unknown discount code: SAVE15
at r (app.js:1:45)
and the original source (before bundling and minifying) was:
// src/checkout/applyDiscount.ts
export interface Cart {
total: number;
items: string[];
}
export function applyDiscount(cart: Cart, code: string): number {
if (code !== "SAVE10") {
throw new Error(`Unknown discount code: ${code}`);
}
return Math.round(cart.total * 0.9);
}
Grab the dist/app.js.map from the same build and install the library:
npm install source-map
This script maps generated line 1, column 45 back to the original position, then prints the offending source line out of sourcesContent:
// unminify.mjs — run with: node unminify.mjs
import fs from "node:fs";
import { SourceMapConsumer } from "source-map";
// The minified frame you are decoding: "at r (app.js:1:45)"
const GENERATED_LINE = 1;
const GENERATED_COLUMN = 45;
const rawMap = JSON.parse(fs.readFileSync("./dist/app.js.map", "utf8"));
await SourceMapConsumer.with(rawMap, null, (consumer) => {
const original = consumer.originalPositionFor({
line: GENERATED_LINE, // 1-based, same as the stack trace
column: GENERATED_COLUMN - 1, // stack traces are 1-based; the library is 0-based
});
console.log(original);
// Print the offending line if the map embeds sourcesContent
const content = consumer.sourceContentFor(original.source, true);
if (content !== null) {
console.log("> " + content.split("\n")[original.line - 1].trim());
}
});
Real output — we ran exactly this against an esbuild-minified bundle:
{
source: '../src/checkout/applyDiscount.ts',
line: 8,
column: 10,
name: null
}
> throw new Error(`Unknown discount code: ${code}`);
app.js:1:45 was the throw on line 8 of applyDiscount.ts. Mind the column arithmetic: stack traces report 1-based columns, while originalPositionFor takes a 1-based line but a 0-based column — forget the - 1 and you can land on the wrong token. (name is null here because the mapped position is mid-statement, not a renamed identifier; query the position of the renamed identifier itself and name comes back applyDiscount — that's how r gets its real name back.)
This works fine for one frame, once. Now multiply by ten frames per trace, thousands of events per day, and a new map for every deploy.
How error trackers unminify at scale
Error trackers run the same lookup server-side on every frame of every incoming event — the process is called symbolication. The pipeline: at build time you upload .map files keyed by release and bundle path; at ingest, the tracker matches each frame's file URL against the uploaded artifacts, decodes the position, restores the function name from names, and pulls context lines from sourcesContent. Trackers cache the parsed maps — re-parsing a multi-megabyte map per event would melt anything.
Symbolication has to happen before grouping. Minified names change every build — your function is r today and x after tomorrow's deploy — so grouping raw frames would split one bug into a fresh issue per release. Unminified frames are stable, so the issue stays one issue with one history.
This is how Catch handles it: you upload source maps with the CLI or a build plugin, ship comment-free hidden bundles, and Catch symbolicates every stack trace server-side before grouping it into issues. The getting-started guide covers the upload step, and it's free for hobby apps — no credit card.
Two adjacent problems round out the picture. Capturing the error in the first place has its own sharp edges — see window.onerror vs addEventListener('error'). And minification isn't browser-only: production server bundles get minified too, so server-side stacks need the same treatment — see Next.js server error tracking with onRequestError.
Why is my stack trace still minified? Common failure modes
When symbolication silently fails, you get half-minified traces — some frames resolve, others stay a.js:1:43892. Check these in order.
The frame's URL doesn't match the map
Symbolication joins on the generated file's path. If the browser reports https://cdn.example.com/assets/app-3f2a91c8.js but you uploaded the map under dist/assets/app-3f2a91c8.js, nothing matches and the frame stays minified. Most upload tools let you set a public path prefix — make it match what the browser actually requests, including any CDN rewrite. The same applies to linked maps: the sourceMappingURL comment resolves relative to the JS file's URL, so a bundle moved by hand to a different directory points at a map that 404s.
The map is from a different build
A map is only valid for the byte-identical bundle it was generated with. If your deploys overwrite app.js in place, a user still on yesterday's cached bundle throws an error and your tracker decodes it with today's map — confidently wrong positions, worse than no map at all. Content-hashed filenames (Vite does this out of the box; in webpack set output.filename: "[name].[contenthash].js") plus tagging uploads with a release identifier fix it. This is the same deploy skew that produces the Failed to fetch dynamically imported module error in Vite — stale HTML asking for chunks that no longer exist.
sourcesContent is missing
If frames resolve to the right file and line but your tracker shows no surrounding code, your build omitted sourcesContent — esbuild's --sources-content=false or Rollup's sourcemapExcludeSources do this to shrink maps. Positions still decode; context doesn't. Re-enable embedded sources for the maps you upload: they get bigger, but they live on your tracker, not your CDN, so users never download a byte of them.
Everything is off by one
If decoded positions consistently point one token left or right of the real code, it's the 1-based/0-based column mismatch from the hand-decoding section — usually in a homegrown symbolication script. Stack traces count columns from 1; the mappings in the file (and the source-map library's column API) count from 0.