Everything that makes JavaScript fast to download makes it impossible to debug. Source maps are the file that undoes that, and they are the most misunderstood artefact in a modern build.

What a source map actually is

A source map is a JSON file that says where each piece of the generated code came from. Nothing more. It contains no executable code and the browser does nothing with it until you open devtools.

The shape has been stable since version 3 of the format, and it is now a TC39 standard rather than a shared Google document. The fields you meet:

FieldContains
versionalways 3
filethe generated file this map belongs to
sourcespaths of the original files
sourcesContentthe original files themselves, verbatim, optional
namesoriginal identifier names lost to mangling
mappingsthe position data, base64 VLQ
ignoreListindices of sources devtools should treat as third-party

sourcesContent is the field with consequences. With it, the map is self-contained and devtools can show your original code without fetching anything else. It is also, literally, your source code in a JSON string. That single field is what makes "should we ship source maps" a real question rather than an obvious yes.

ignoreList is newer and underused. It marks entries in sources as framework or dependency code, and Chrome devtools then hides them from stack traces and steps over them in the debugger. Bundlers emit it for node_modules automatically, and it makes a React stack trace considerably shorter.

Inside the mappings field

Open a .map file and one field dominates: a long string that looks like line noise.

"mappings": "AAAA,SAASA,EAAEC,GAAG,OAAOA,EAAI,EAAE,CAAC"

It is a list of positions, encoded so it does not take megabytes. Semicolons separate lines of the generated file; commas separate segments within a line. Each segment carries up to five numbers: the column in the generated file, an index into sources, the original line, the original column, and optionally an index into names.

Two tricks keep it small. Every number is a delta from the previous segment rather than an absolute value, so the digits stay tiny. And each delta is base64 VLQ encoded, a variable-length format where the low bit carries the sign, five bits carry data and the top bit says whether another group follows. A minified bundle needs a segment for roughly every token, and this is what turns tens of millions of digits into a few hundred kilobytes.

You will never write this by hand, but knowing the structure explains two things you will meet. A "cheap" devtool setting omits column data, which is fine for readable source and useless for minified code where an entire module lives on line 1. And the mapping is positional only: it points at a location in a file, so if the file it points into is not the file you built, the result is a confident answer to the wrong question. That is the failure mode in the stale maps section below.

How the browser finds the map

Two mechanisms, and the second one is nearly forgotten.

The usual one is a comment on the last line of the JavaScript file:

//# sourceMappingURL=app.4f3a9c1e.js.map

It must be the last line, since anything after it is ignored. The older //@ form was replaced because it collided with IE's conditional compilation. For CSS the same comment uses /*# sourceMappingURL=... */.

The other is an HTTP response header, SourceMap: /maps/app.js.map, which attaches a map without touching the file at all. It is genuinely useful: you can serve maps to your own IP range or behind an authenticating proxy while everyone else gets a bundle with no hint that maps exist. Support has been in Chrome and Firefox devtools for years, and almost nobody uses it.

Third form: an inline map, where the whole JSON is base64 in a data URI appended to the file. Convenient in development, indefensible in production, since every visitor downloads a file several times bigger than the code. If you ever need to look inside one, decode the part after base64, with our base64 decoder and drop the result into the JSON formatter to see the fields laid out.

Should you ship them?

The objection is usually performance, and it is misplaced. A browser fetches a .map file only when devtools are open. For a normal visitor the entire cost is the length of the sourceMappingURL comment, which is under a hundred bytes.

The real question is disclosure. A map with sourcesContent is your source code, comments and all, served from your own CDN. Whether that matters depends on what the code is. A marketing site, no. A pricing engine or a client with licensing logic in it, quite possibly.

Worth being clear about a related confusion: minification is not protection. Names get shortened, whitespace disappears, and the logic is entirely intact and readable to anyone who wants it, which is the distinction we work through in minify, uglify, obfuscate. If your threat model requires the client code to be unreadable, source maps are not the thing standing in your way. If it does not, publishing maps is a gift to whoever is debugging your site at 2am, and that person is usually you.

Our own position: ship maps for anything public-facing, use hidden maps where the bundle is a product, and never let the decision be made accidentally by a default in a build config nobody has read.

Hidden maps and error trackers

The middle path is well supported. Generate the map, do not advertise it, upload it to the error tracker, and delete it from the deployed output:

  • Vite: build.sourcemap: 'hidden'
  • webpack: devtool: 'hidden-source-map'
  • esbuild: --sourcemap=external
  • Next.js: productionBrowserSourceMaps for public maps; the Sentry plugin handles the hidden case and the upload together

Then the tracker symbolicates on its side: your team reads Cart.tsx:112, the public gets app.js:1:48210, and nothing on the CDN reveals the source.

The part that fails in practice is matching a stack trace to the right map. Path-based matching breaks whenever a build directory changes or two deploys share a file name. Debug ids fix it properly: a unique identifier is injected into both the bundle and its map at build time, and the tracker matches on that identifier instead of guessing from a URL. Sentry's bundler plugins do this automatically now, and it removed most of the "unminified frames missing" tickets we used to see.

Why the line numbers are wrong

A map that points at the wrong place is worse than no map, because you trust it. Four causes, in the order we run into them:

  • Version mismatch. The map was built in a different run than the bundle the user executed. A rebuild between deploy and upload is enough. Content hashes in file names plus debug ids fix this.
  • Cached bundle, new map. The user is running yesterday's JavaScript out of the browser cache while the tracker holds today's map. Long-lived immutable caching for hashed files prevents it, which is the same argument as in HTTP caching without tears.
  • A transform after the map was made. A CDN that re-minifies, an inline-script injector, an A/B testing proxy that rewrites the bundle. The map describes a file that no longer exists.
  • Two-stage builds without map merging. TypeScript to JavaScript, then bundling, then minification. Each step must consume the previous map and emit a merged one. Tools handle this when configured correctly and silently produce a map to the intermediate file when not, which is why a stack trace occasionally points at compiled output rather than your .ts file.

If you want to check whether a map is even plausible before blaming the tracker: the file field should name the bundle you deployed, sources should list paths you recognise, and the number of semicolons in mappings should be close to the number of lines in the generated file. A map for a minified bundle usually has very few semicolons, because there are very few lines. Two, when the file has one line and a trailing newline, is normal rather than broken.

The bundler settings worth knowing

webpack's devtool has more than a dozen values and the naming is compositional rather than descriptive:

PrefixMeans
eval-modules wrapped in eval with the map inline, fastest rebuilds, development only
cheap-line mappings only, no columns
module-maps back to your source, not to what the loader produced
hidden-emit the map, do not reference it from the bundle
inline-base64 the map into the bundle

In practice: eval-cheap-module-source-map for development, source-map or hidden-source-map for production, nothing else. Vite defaults to maps on in development and off in production, so shipping them is an opt-in you have to write down.

A last note on the minifier itself. Terser and esbuild both emit maps for their own transformation, and both need the incoming map to chain correctly through a multi-step build. If you are checking what a given file looks like after minification, or comparing what two settings produce before wiring them into a pipeline, our JavaScript minifier runs Terser in your browser and shows the gzip size of the result; the uglifier is the same engine with top-level mangling enabled, which is the setting that makes a map indispensable rather than merely convenient. The specification is at tc39.es/source-map if you need the exact VLQ rules.

Source map questions

Should I deploy source maps to production?

Deploy them if your bundle is not a trade secret, and use hidden source maps if it is. Browsers only download a .map file when devtools are open, so ordinary visitors never pay for it, and having real file names and line numbers in a production stack trace is worth a great deal during an incident. If the code must stay private, generate the maps, upload them to your error tracker and either omit the sourceMappingURL comment or keep the .map files off the public server.

Do source maps slow down my site?

No, for visitors. The sourceMappingURL comment is a few dozen bytes at the end of the file, and the map itself is fetched only when devtools are open, so a 4 MB map costs a normal user nothing. Inline source maps are the exception and the reason people believe otherwise: those embed the entire map as a base64 data URI inside the JavaScript file, which every visitor downloads and every parser walks past. Inline maps belong in development only.

Why does DevTools say "Could not load content for" a source file?

The map was found but the original sources were not. Either sourcesContent is missing, so the browser tried to fetch each path in the sources array and got a 404, or the paths are relative to a build directory that does not exist on the server, or the files sit on another origin without CORS headers. Setting sourcesContent (webpack and Vite include it by default) makes the map self-contained and removes the whole class of failure at the cost of a larger file.

How do I keep source maps private but still get readable stack traces?

Generate hidden source maps: the .map file is produced but no sourceMappingURL comment is appended, so nothing advertises it. Upload the maps to Sentry, Datadog or whichever tracker you use as part of the deploy, then delete them from the public output. Symbolication happens on the tracker’s side, users get an unreadable bundle, and your team gets real file names. In Vite that is build.sourcemap: "hidden", in webpack the hidden-source-map devtool.

What is the mappings field in a .map file?

It is the actual map: a string of base64 VLQ-encoded numbers where semicolons separate generated lines and commas separate segments within a line. Each segment holds up to five values, all stored as deltas from the previous segment: the column in the generated file, the index into sources, the original line, the original column, and optionally the index into names. Deltas plus VLQ is what keeps the field to a few hundred kilobytes for a bundle that would otherwise need millions of digits.

Why does my production stack trace point at the wrong line?

Almost always a version mismatch: the map uploaded to the error tracker was built from different source than the bundle the user ran, usually because a rebuild happened between deploy and upload, or because two deploys share a file name. Debug ids solve it properly by stamping the same identifier into the bundle and its map so the tracker matches on identity rather than on path. Failing that, include a content hash in the file name and upload the map in the same build step that produced it.

Can someone reconstruct my original source code from a source map?

If the map contains sourcesContent, yes, completely: that field holds the original files verbatim, comments included, so anyone who can fetch the .map has your pre-build source. Without sourcesContent they get file names, line numbers and identifier names, which is still a good outline of your project structure. Treat a published source map as publishing the source, and use hidden maps when that is not what you want.

Which webpack devtool or Vite sourcemap setting should I use?

For development, webpack’s eval-cheap-module-source-map rebuilds fastest and still maps to your original files; Vite has development maps on by default. For production, source-map when you are happy to publish, hidden-source-map when the map is only for the error tracker. Avoid the cheap variants in production because they drop column information, which matters for minified code where a whole function sits on one line, and avoid inline-source-map anywhere users will download the bundle.