Source Map Hashes and Debug Builds

Turning source maps on is a one-word config change, and it renames every JavaScript file in dist/. That surprises people, because the code did not change — only the debugging metadata did. But a bundler computes [contenthash] over the bytes it actually writes, and in most source-map modes those bytes include a trailing //# sourceMappingURL= comment. Add the comment and the digest moves; remove it and the digest moves back. This page works through which modes touch the hashed bytes, which do not, what should reach the CDN versus an error tracker, and how to keep a debug build from ever landing on a production URL.

The Comment Is Part of the File

A source map is a separate JSON document, but the browser has to be told it exists. The conventional signal is a comment appended to the very end of the generated file:

// tail of dist/assets/app.7b4e9c2f.js
console.log("ready");
//# sourceMappingURL=app.7b4e9c2f.js.map

That comment is forty-odd bytes of the file’s content. The bundler hashes the emitted chunk, so those bytes are inside the digest. A build with maps and a build without maps therefore never agree on a single output filename, even from an identical commit with identical dependencies. This is not the drift that deterministic build outputs exists to eliminate — the content genuinely differs — but it looks identical in a CI log, and it is a common reason a release that “changed nothing” ships a fully renamed asset set.

Inline maps make the effect enormous rather than subtle. Instead of a filename, the comment carries the entire map as a base64 data URI, so the digest is computed over the bundle plus a base64-encoded copy of your original source.

One flag, three filenames Three rows compare a map-free build, an external source map build and an inline source map build. Each row shows the build mode, the final line of the emitted bundle, and the output filename that results. Only the map-free row keeps the baseline hash. One config flag, three different filenames Build mode Last line of the bundle Filename it produces devtool: false sourcemap: false no trailing comment bytes end at the last token app.3f8d2a1c.js the map-free baseline devtool: source-map sourcemap: true //# sourceMappingURL= app.7b4e9c2f.js.map adds 44 bytes app.7b4e9c2f.js every chunk renamed inline-source-map sourcemap: inline //# sourceMappingURL=data: application/json;base64 adds about 1.2 MB app.e10a4d6b.js renamed and six times larger
The generated code is byte-identical in all three rows; only the tail differs, and the tail is inside the digest.

Mode Matrix

The useful distinction is not “maps on or off” but “does this mode append anything to the chunk”. Modes that append change every hash. Modes that emit a map file without a reference leave the chunk untouched, which means a hidden-map build and a map-free build produce the same filenames — the property that makes hidden maps the default recommendation for production.

Mode (Webpack 5 / Vite 5) Chunk bytes vs a map-free build Map emitted Debuggability What reaches the browser
devtool: false / sourcemap: false Identical — this is the baseline None Minified frames only Bundle
devtool: 'source-map' / sourcemap: true Differ — comment appended External .map with sourcesContent Full original source Bundle + map, both public
devtool: 'hidden-source-map' / sourcemap: 'hidden' Identical to the baseline External .map, no reference Full, for whoever holds the map Bundle only
devtool: 'nosources-source-map' Differ — comment appended External .map, no sourcesContent File, line, column and names; no code Bundle + map, safe to publish
devtool: 'inline-source-map' / sourcemap: 'inline' Differ by the whole base64 map None separate Full original source One very large bundle
devtool: 'eval-source-map' Structurally different output None separate Full, development only Never ship this

Two secondary knobs modify the rows rather than adding new ones. The cheap- prefix drops column mappings, which shrinks the map and speeds the build at the cost of landing you on the right line but the wrong expression. The -module- infix maps through loader transforms back to the file you actually wrote rather than to the post-Babel intermediate. Neither changes whether a comment is appended, so neither changes the hash story.

nosources-source-map is the mode people reach for when they want public maps without publishing source. It is a real reduction in exposure — the map has mappings and identifier names but no sourcesContent array — though bear in mind that identifier names alone often reveal internal module and function naming.

Webpack 5

The production configuration below emits hidden maps into a separate directory. Because hidden-source-map appends nothing, the [contenthash:8] values match what the same commit produces with devtool: false, so switching map generation on does not churn the CDN.

// webpack.prod.js
const path = require('path');

module.exports = {
  mode: 'production',
  devtool: 'hidden-source-map',
  entry: { app: './src/index.js' },
  output: {
    path: path.resolve(__dirname, 'dist'),
    filename: 'assets/[name].[contenthash:8].js',
    chunkFilename: 'assets/[name].[contenthash:8].chunk.js',
    sourceMapFilename: 'maps/[file].map',
    publicPath: '/',
    clean: true,
  },
  optimization: {
    moduleIds: 'deterministic',
    chunkIds: 'deterministic',
    runtimeChunk: 'single',
  },
};

sourceMapFilename: 'maps/[file].map' sends every map to dist/maps/assets/app.3f8d2a1c.js.map, one directory tree away from the files you sync to the CDN. Eight hex characters is the right default; move to [contenthash:12] or [contenthash:16] in a monorepo emitting thousands of chunks.

For finer control, drop devtool entirely and drive SourceMapDevToolPlugin directly. This is the only way to get a map whose comment placement, source naming and filtering you fully control:

// webpack.maps.js — maps generated, chunk bytes left alone
const path = require('path');
const webpack = require('webpack');

module.exports = {
  mode: 'production',
  devtool: false,
  entry: { app: './src/index.js' },
  output: {
    path: path.resolve(__dirname, 'dist'),
    filename: 'assets/[name].[contenthash:8].js',
    publicPath: '/',
    clean: true,
  },
  optimization: {
    moduleIds: 'deterministic',
    chunkIds: 'deterministic',
    runtimeChunk: 'single',
  },
  plugins: [
    new webpack.SourceMapDevToolPlugin({
      filename: 'maps/[file].map',
      append: false,
      noSources: false,
      moduleFilenameTemplate: 'webpack://storefront/[resource-path]',
      exclude: /vendor/,
    }),
  ],
};

append: false is the load-bearing line. It suppresses the //# sourceMappingURL= comment, so the emitted chunk is byte-identical to the devtool: false output and the digest is unchanged. exclude: /vendor/ skips map generation for third-party chunks, which is usually most of your map volume and almost none of your debugging value — the same split argued in the guide on hashing vendor bundles versus app bundles.

Vite 5

Vite exposes a single build.sourcemap option accepting false, true, 'inline' or 'hidden'. The configuration below drives production and debug builds from the same file while keeping their outputs in separate directories:

// vite.config.js
import { defineConfig } from 'vite';

export default defineConfig(({ mode }) => ({
  build: {
    outDir: mode === 'debug' ? 'dist-debug' : 'dist',
    emptyOutDir: true,
    sourcemap: mode === 'debug' ? true : 'hidden',
    rollupOptions: {
      output: {
        entryFileNames: 'assets/[name].[hash:8].js',
        chunkFileNames: 'assets/[name].[hash:8].js',
        assetFileNames: 'assets/[name].[hash:8][extname]',
      },
    },
  },
}));

Run the production build with vite build and the debug build with vite build --mode debug. The debug build writes to dist-debug/, carries a visible sourceMappingURL comment, and therefore produces a different set of filenames — which is exactly what you want, because those files are never meant to be deployed alongside the production set.

Note that sourcemap: 'hidden' still writes .map files into outDir. They are ordinary build output; nothing prevents a naive aws s3 sync dist/ s3://bucket/ from publishing them. Either move them out before the sync or exclude them explicitly.

Ship the Map, or Upload It?

Three defensible answers exist, and the choice turns on whether your original source is something you are willing to publish.

Where the map file should go A decision tree. First ask whether production stack traces are needed; if not, emit no map and keep the baseline hash. If they are, ask whether the original source is proprietary. If it is not, emit a public map with sourcemap true and accept that hashes move. If it is, emit a hidden map, upload it to the error tracker, and keep the baseline hash. Where the map file should go Do you need production stack traces? no yes Minified frames are fine no map at all Is the original source proprietary? devtool: false sourcemap: false hash stays at baseline no sourcemap: true map ships to the CDN every hash moves once yes hidden-source-map upload to the tracker hash stays at baseline
Only the middle branch pays a hash cost, and it pays it once — on the release that turns maps on, not on every release afterwards.

If you keep maps private, the release step uploads them out of band — a .map file posted to your error tracker’s release API alongside the release identifier, then deleted from the deploy artifact. The tracker symbolicates server side, and nothing on the public origin references the map at all.

There is a middle path worth knowing about: the SourceMap: response header. Instead of embedding the reference in the file, the server advertises it in the response, which keeps the chunk bytes at the map-free baseline while still letting DevTools find the map. Older browsers used X-SourceMap for the same purpose; emitting both costs nothing.

# Fingerprinted bundles: comment-free bytes, map advertised by header
location ~* ^/assets/(?<asset>.+\.[a-f0-9]{8}\.js)$ {
    root /var/www/dist;
    add_header Cache-Control "public, max-age=31536000, immutable" always;
    add_header SourceMap "/maps/assets/$asset.map" always;
    add_header X-SourceMap "/maps/assets/$asset.map" always;
}

# The maps themselves: reachable only from the office egress range
location ~* \.map$ {
    root /var/www/dist;
    allow 203.0.113.0/24;
    deny all;
    add_header Cache-Control "public, max-age=31536000, immutable" always;
}

Because the header lives outside the file, you can add or remove it without renaming a single asset — a genuine advantage when your cache-key architecture treats the hashed URL as permanent.

What Actually Ships

Two destinations, one build A single build writes a hashed bundle and a hashed map into the dist directory. The bundle is synced to the CDN origin with immutable caching. The map is uploaded to the error tracker at release time and never published. A footnote records that the SourceMap response header points DevTools at the private copy. Two destinations, one build Build vite build dist/ assets/app.3f8d2a1c.js maps/app.3f8d2a1c.map CDN origin public, immutable bundle only Error tracker release upload never public The SourceMap response header points DevTools at the private copy without touching the bundle.
The bundle and the map leave the build together and then part company: one becomes a public immutable URL, the other becomes a release artifact.

A Debug Build Must Never Overwrite a Production Hash

The rule sounds obvious until you see how it gets broken. A developer needs to reproduce a production-only bug, rebuilds with sourcemap: true, and pushes the result to the same bucket prefix “just to look at it”. If the output filenames still carry content hashes the collision is unlikely, because the debug bytes hash differently. The damage happens when they do not: an entry point without a hash, a [name].js chunk pattern left over from an older config, or a deploy script that also refreshes index.html.

At that point the same URL has served two different payloads, and the first one went out with max-age=31536000, immutable. Every edge node and every browser that cached the production copy will keep it; every cache that fetched after the debug push will keep the debug copy. A purge clears the edge but cannot reach browsers, because immutable was a promise that the URL would never change meaning.

How a debug build poisons an immutable URL Panel one: a production deploy publishes a minified bundle with an immutable cache directive and the edge stores it for a year. Panel two: a debug rebuild writes the same filename with unminified content and syncs to the same prefix. Panel three: the edge keeps its old copy, browsers keep theirs, and no purge can recall either until max-age expires. How a debug build poisons an immutable URL Step 1 — production deploy app.3f8d2a1c.js, 240 KB Cache-Control: immutable edge stores it for a year The URL now means exactly these bytes Step 2 — debug rebuild same output filename unminified, 1.4 MB synced to the same prefix Origin bytes changed the URL did not Step 3 — the trap edge keeps its old copy browsers keep theirs a purge cannot reach them No recall until max-age expires
Immutable caching is a promise about a URL, not about a file — break it once and the population splits into two permanent cohorts.

Three guards make this structurally impossible. Write debug builds to a different outDir, sync them to a different origin prefix, and assert in CI that no path exists in both trees with differing content:

#!/usr/bin/env bash
# scripts/assert-debug-isolation.sh
# A debug build may reuse a production URL only if the bytes behind it match.
# Usage: ./scripts/assert-debug-isolation.sh dist dist-debug

set -euo pipefail

PROD_DIR="${1:-dist}"
DEBUG_DIR="${2:-dist-debug}"
status=0

while read -r rel; do
  if [ -f "${DEBUG_DIR}/${rel}" ] && ! cmp -s "${PROD_DIR}/${rel}" "${DEBUG_DIR}/${rel}"; then
    echo "COLLISION ${rel} — same URL, different bytes"
    status=1
  fi
done < <(cd "$PROD_DIR" && find . -type f \( -name '*.js' -o -name '*.css' \) \
           | sed 's|^\./||' | sort)

if [ "$status" -eq 0 ]; then
  echo "OK: the debug build writes no production URL with different content"
fi
exit "$status"

If a collision does reach production, treat it as a bad release rather than a cache problem: the recovery path is the one described in rolling back a content-hashed release, where you republish the entry point pointing at a fresh URL rather than trying to repair a poisoned one.

Verification: Diff the Emitted Hashes

The fastest way to see the effect on your own project is to build the same commit three ways and compare the filenames. This script does that with Vite; the Webpack equivalent swaps in three config files.

#!/usr/bin/env bash
# scripts/sourcemap-hash-diff.sh
# Builds the working tree with no maps, hidden maps and public maps,
# then reports which modes change the emitted filenames.

set -euo pipefail

rm -rf dist-nomap dist-hidden dist-public

npx vite build --outDir dist-nomap  --emptyOutDir --sourcemap false
npx vite build --outDir dist-hidden --emptyOutDir --sourcemap hidden
npx vite build --outDir dist-public --emptyOutDir --sourcemap true

names() { find "$1" -type f -name '*.js' | sed 's|.*/||' | sort; }

echo "=== hidden maps vs no maps ==="
if diff -q <(names dist-nomap) <(names dist-hidden) > /dev/null; then
  echo "identical filenames — hidden maps do not touch the hash"
else
  diff <(names dist-nomap) <(names dist-hidden) || true
fi

echo
echo "=== public maps vs no maps ==="
diff <(names dist-nomap) <(names dist-public) || true

echo
echo "=== bytes appended by the public-map build ==="
for f in dist-public/assets/*.js; do
  tail -c 120 "$f" | grep -o 'sourceMappingURL=[^ ]*' || true
done

The expected result is that the first comparison prints nothing and the second prints a full rename of every chunk. If the first comparison also shows differences, something else in your pipeline is non-deterministic and the source-map setting is not the cause — start from debugging phantom hash changes in CI instead.

When to Reconsider

Publishing maps openly is the right call more often than the security instinct suggests. If your frontend is open source, or the bundle is thin glue over a documented API, sourcemap: true costs one round of renamed assets and buys every engineer readable stack traces without a tracker integration. Take the hash churn on a release where you expect churn anyway.

Hidden maps are worse than useless if nothing ever uploads them. A dist/maps/ directory that CI deletes at the end of the job gives you the debuggability of devtool: false and the build time of devtool: 'source-map'. Verify the upload step exists before choosing the mode.

Inline maps have exactly one defensible home: a preview or branch deployment that no real user touches, where the convenience of a single file outweighs a bundle that is several times larger. Never let that configuration reach the same origin prefix as production, for the reason drawn out above.

Framework wrappers may also take the decision out of your hands. Next 14 exposes productionBrowserSourceMaps, which publishes maps to the CDN when enabled, and Astro 4 forwards vite.build.sourcemap unchanged — check what your framework does before assuming the bundler defaults apply.

Frequently Asked Questions

Does turning source maps on change my CSS hashes too?

Yes, if the CSS pipeline emits maps. A //# sourceMappingURL= comment appended to a stylesheet is part of the stylesheet’s bytes, so the extracted CSS file gets a new digest the same way a JavaScript chunk does. In Vite the single build.sourcemap option governs both; in Webpack, MiniCssExtractPlugin follows the devtool setting unless you override it.

Why did my hashes change even though I used hidden-source-map?

Something other than the comment moved. The usual culprits are a minifier that switched on sourceMap support and changed its output slightly, a bundler upgrade, or a lockfile change landing in the same commit. Rebuild the previous commit with the new configuration to isolate the variable — if the hashes match there, the source-map mode is not responsible.

Are .map files safe to serve with Cache-Control: immutable?

Yes, provided the map filename carries the bundle’s content hash, which it does when you derive it from [file] or from Vite’s default naming. The map is as immutable as the chunk it describes. Serve it with the same one-year max-age and the same immutable directive you use for the bundle.

Should the source map be included in a Subresource Integrity hash?

No. SRI covers the resource the browser loads — the script or stylesheet — and the map is fetched separately by DevTools, not by the page. Adding a map reference changes the bundle bytes and therefore invalidates a previously computed SRI digest, which is a good reason to settle your source-map mode before wiring up SRI hash generation in your build pipeline.