Generating Hidden Source Maps with webpack 5

You want production stack traces that resolve to real filenames and line numbers, but you do not want anyone with browser DevTools to download your original source. The hidden-source-map devtool in webpack 5 solves exactly this: it writes a complete .map next to each bundle yet omits the trailing //# sourceMappingURL= comment, so no browser fetches it automatically. This how-to is part of Configuring webpack for Production Source Maps, which sits under the broader Source Map Generation & Stack Trace Debugging reference, and it focuses narrowly on getting the hidden-map emit correct and proving the comment is gone.

Symptom / Trigger

You switched a production build to full source maps and a security scan (or a curious user) discovered that main.9f2a1c.js.map is downloadable straight from your CDN. Opening it in the browser reveals every line of your business logic. The bundle footer looks like this:

// dist/main.9f2a1c.js — last line of the shipped bundle
(()=>{"use strict";var e=42;console.log(e)})();
//# sourceMappingURL=main.9f2a1c.js.map

That last comment is the trigger. Any browser that loads the script reads the sourceMappingURL directive, requests the .map from the same origin, and — if your server or CDN serves it — reconstructs your unminified source inside DevTools. The goal is to keep generating the map for your error tracker while deleting that public breadcrumb.

The failure often stays invisible for weeks. Nothing in your application breaks, no error is logged, and the map resolves stack traces perfectly, so the setup looks correct in every functional sense. The exposure surfaces only when someone thinks to open the Network tab, notices a .map request returning 200 OK, or points a crawler at your asset directory. By then the source has been publicly reachable for the entire life of that release, and every intermediate CDN cache may hold a copy. Treating the presence of the annotation as a build-time defect — something you assert against in CI — is far more reliable than hoping a reviewer spots it later.

Root Cause Explanation

webpack’s devtool: 'source-map' does two separate things that people conflate. First, it runs the source-map generation pass and writes an external .map artifact to your output directory. Second, it appends a sourceMappingURL annotation to the bottom of every emitted chunk so consumers can locate that artifact. The annotation is what makes the map “public” — it is a discovery hint baked into the shipped JavaScript, independent of whether the .map is actually reachable.

// webpack.config.js — leaks the discovery hint into production
module.exports = {
  mode: 'production',
  devtool: 'source-map', // writes .map AND appends sourceMappingURL comment
};

hidden-source-map decouples the two behaviors. It performs the identical generation pass — same mappings, same sourcesContent, same column accuracy — but suppresses the annotation step. The .map still lands on disk so your CI can upload it to Sentry, Datadog, or Bugsnag; the browser simply has no pointer to it. Symbolication moves off the client and onto your error tracker, which matches minified frames to the uploaded map by filename and content hash instead of by an inline URL.

It helps to picture the map’s lifecycle as three distinct locations rather than one file. On the build machine the map is generated and needed. In the error tracker the map is stored privately and consulted whenever a minified frame arrives. On the public CDN the map should never appear at all. The source-map devtool blurs these locations together by advertising the map from inside the bundle, which effectively forces the CDN to become a fourth, unwanted host for it. hidden-source-map keeps the three locations separate by refusing to advertise anything: the map exists only where you deliberately put it. Every step in the fix below is really an operation on one of those three locations — generate, upload, then withhold from the public deploy.

source-map versus hidden-source-mapLeft panel shows source-map emitting a bundle with a public sourceMappingURL comment; right panel shows hidden-source-map emitting the same map with no comment.source-mapWrites .map fileAdds URL commentBrowser auto-fetcheshidden-source-mapWrites .map fileNo URL commentUpload to tracker

The trade-off is that you take on responsibility for delivering the map to the tracker. With source-map the browser does the resolution for free; with hidden-source-map your pipeline must upload the artifact and then keep it out of the public deploy. If you are still weighing which devtool to standardize on across environments, see Choosing the Right Webpack devtool Setting.

Step-by-Step Fix

  1. Set devtool: 'hidden-source-map' in your production webpack config only.
// webpack.prod.js — production-only, never in the dev config
module.exports = {
  mode: 'production',
  devtool: 'hidden-source-map', // full map on disk, no public URL comment
};
  1. Give bundles a content hash so each map pairs with exactly one build artifact.
// webpack.prod.js — output block
output: {
  filename: '[name].[contenthash].js', // unique name ties bundle to its .map
  path: __dirname + '/dist',
  clean: true, // wipe stale bundles and maps between builds
},
  1. Run the production build and confirm both the bundle and its map are emitted.
# Build with the production config and list the emitted artifacts
npx webpack --config webpack.prod.js
ls dist/*.js dist/*.map   # both main.<hash>.js and main.<hash>.js.map appear
  1. Upload every .map to your error tracker before the bundle reaches users.
# Upload maps to Sentry, keyed by the release you are about to deploy
npx sentry-cli sourcemaps upload --release "$RELEASE" ./dist
  1. Strip the .map files from the artifact you actually publish to the CDN.
# Remove maps from the deploy directory so nothing is served publicly
find dist -name '*.map' -delete   # runs AFTER the upload step, never before

Hidden source map pipelineVertical flow from building with hidden-source-map, to uploading maps to the tracker, to deploying the bundle without maps.Build with hidden mapUpload .map to trackerDeploy bundle only

The ordering of steps four and five is not cosmetic. If you delete maps before uploading, the tracker has nothing to symbolicate against and every production frame stays minified. Wire the two commands as a single sequential stage so a failed upload aborts the deploy instead of silently shipping unsymbolicated traces.

Two details make this workflow robust in practice. First, pass a stable --release identifier that matches the release value your runtime SDK reports; the tracker joins events to maps on that key, so a mismatch means the map is present but never consulted. Deriving the release from the same content hash or git SHA in both the build and the SDK config removes the guesswork. Second, treat the staging copy and the deploy copy as separate directories. Build into dist, copy the maps to a private upload-staging folder for the upload step, and let the deploy stage publish dist only after its maps are removed. Keeping the two copies physically apart means the delete in step five can never race against an in-flight upload, and it gives you an archived artifact you can re-upload later without rebuilding.

Verification

Prove the fix by asserting that no shipped bundle contains a sourceMappingURL reference, while the maps still exist in the upload staging directory:

# 1. No bundle in the deploy dir may reference a map (expected: 0)
grep -rl "sourceMappingURL" dist/*.js | wc -l
# Output: 0

# 2. The maps exist in the staging copy used for upload (expected: >= 1)
ls upload-staging/*.map | wc -l
# Output: 1 (or more, one per chunk)

# 3. Confirm the map is well-formed and carries source content
node -e "const m=require('./upload-staging/main.'+require('fs').readdirSync('upload-staging').find(f=>/main\..*\.js\.map$/.test(f)).match(/main\.(.*)\.js\.map/)[1]+'.js.map'); console.log('version', m.version, '| sources', m.sources.length)"
# Output: version 3 | sources 128

If step one returns anything other than 0, a chunk still carries the public annotation — usually because a stray devtool: 'source-map' override or a SourceMapDevToolPlugin instance re-added it. Make this grep a hard gate in CI rather than a manual spot check: run it against the exact directory you are about to publish, and fail the pipeline with a non-zero exit code the moment the count is not zero. Because the assertion runs on build output, it catches the regression whether it came from a config change, a merged branch, or a plugin upgrade that quietly changed defaults.

The second and third checks matter just as much as the first. A bundle with no annotation but no corresponding uploaded map produces clean-looking builds and useless production traces, so verifying that the maps still exist in staging closes the other half of the loop. Parsing the map with version and sources confirms it is a real Source Map v3 document with source paths intact, which rules out a truncated or empty artifact slipping through when disk space or an interrupted write corrupts the file.

Edge Cases & Gotchas

  • Double generation: setting devtool: 'hidden-source-map' and also instantiating SourceMapDevToolPlugin manually makes webpack emit maps twice and can re-append the URL comment. Pick one mechanism. Use the plugin directly only when you need a custom map filename or publicPath.
  • Runtime chunks and vendor splits: optimization.splitChunks produces multiple .map files. Your upload and delete steps must glob all of them (dist/**/*.map), not just main.*.map, or vendor frames stay unsymbolicated.
  • The append option still works: if a downstream tool re-inlines a sourceMappingURL, audit any SourceMapDevToolPlugin({ append: ... }) usage — passing a non-false append value cancels the “hidden” behavior entirely.
  • CDN caching after path changes: if maps were briefly public, purge the CDN cache for the .map paths after removing them; a cached copy remains downloadable until the TTL expires or you invalidate it. See Fixing Incorrect Source Map Paths After CDN Deployment for related path issues.

Map location across stagesHorizontal pipeline showing the map present on the build machine, present in the error tracker, and absent from the public CDN.Build: map presentTracker: map storedCDN: no map

FAQ

Does hidden-source-map produce a less accurate map than source-map?

No. The two devtools run the identical generation pass and emit byte-for-byte equivalent .map files, including full line and column mappings and the sourcesContent array. The only difference is the omitted sourceMappingURL comment in the bundle. Your error tracker resolves frames with the same precision either way, so you lose no fidelity by switching.

How do I confirm a map actually reached my error tracker?

Trigger a deliberate error in production and open the resulting event. If the stack frames show your original filenames and line numbers instead of main.<hash>.js:1:4821, the upload succeeded. With Sentry you can also run sentry-cli sourcemaps explain <event-id>, which reports whether a matching artifact was found for that release and points out mismatched release names or missing files.

Should I keep the .map files as build artifacts even after uploading?

Yes, archive them outside the public deploy directory. Keeping the exact map that matches a shipped release lets you re-upload it if the tracker prunes old artifacts, or symbolicate an archived crash log manually. Store them in a private bucket keyed by release, and never in the directory your CDN serves.