Fixing Incorrect Source Map Paths After CDN Deployment

When you deploy Webpack-built assets to a CDN such as CloudFront, Fastly, or Cloudflare CDN and leave output.publicPath pointing at your origin server, every bundle ships a sourceMappingURL comment that resolves to the wrong host — causing 404s in DevTools and symbolication failures in error tracking SDKs. This guide is a focused supplement to Configuring Webpack for Production Source Maps and the broader Source Map Generation & Stack Trace Debugging reference; read those first to understand how Webpack attaches source map comments before applying the CDN-specific fixes below.

sourceMappingURL CDN path: broken vs fixed Left panel shows a bundle with a relative sourceMappingURL requesting the map from the origin server and receiving a 404. Right panel shows a bundle with an absolute CDN URL in sourceMappingURL successfully fetching the map from the CDN with a 200 OK. BEFORE — Broken main.abc123.js //# sourceMappingURL=main.abc123.js.map origin server https://app.example.com 404 Not Found map file not on origin → DevTools fail AFTER — Fixed main.abc123.js //# sourceMappingURL=https://cdn.example.com/… CDN edge node https://cdn.example.com 200 OK map served from CDN → stack traces resolve

Symptom / Trigger

DevTools Network panel shows a failed request for the source map. The error appears in the Console as well when an error tracking SDK (Sentry, Datadog RUM, Bugsnag) attempts to symbolicate an event:

GET https://app.example.com/static/js/main.abc123.js.map  404 (Not Found)

DevTools failed to load source map:
  Could not load content for https://app.example.com/static/js/main.abc123.js.map:
  HTTP error: status code 404, net::ERR_HTTP_RESPONSE_CODE_FAILURE

The identical failure surface appears in error tracking dashboards as unresolved frames:

Error: Cannot read properties of undefined (reading 'id')
  at t.<anonymous> (main.abc123.js:1:38291)   ← minified, not symbolicated

Both failures share the same root cause: the sourceMappingURL comment at the bottom of the compiled bundle names a URL that does not exist on the CDN.

Root Cause Explanation

Webpack appends //# sourceMappingURL=<path> to every emitted JavaScript chunk. The path it writes is derived directly from output.publicPath. When you leave publicPath at its default (auto or /) and then upload dist/ to a CDN, the comment in the bundle still points back at the origin server:

// webpack.config.js — broken pattern
module.exports = {
  mode: 'production',
  devtool: 'source-map',    // or 'hidden-source-map'
  output: {
    path: path.resolve(__dirname, 'dist'),
    filename: '[name].[contenthash].js',
    // publicPath is unset → defaults to '/' or 'auto'
    // Webpack writes: //# sourceMappingURL=main.abc123.js.map
    // Browser resolves: https://app.example.com/main.abc123.js.map
    // CDN holds the file: https://cdn.example.com/main.abc123.js.map  ← mismatch
  },
};

When publicPath is relative or missing, the browser resolves the sourceMappingURL against the HTML page origin. The map file lives on the CDN origin, not the app origin, so every fetch returns 404.

Step-by-Step Fix

Step 1: Identify the broken sourceMappingURL comment

Inspect the deployed bundle to confirm what path Webpack actually wrote. The comment is always the last line of the file:

# Fetch the bundle and print the final line
curl -s https://cdn.example.com/static/js/main.abc123.js | tail -1
# Expected broken output:
# //# sourceMappingURL=main.abc123.js.map

If the path is relative (no scheme + host), proceed with the steps below.

Step 2: Set output.publicPath to the absolute CDN URL

Set output.publicPath to the full CDN origin. Webpack prepends this value to every asset URL it writes, including sourceMappingURL:

// webpack.config.js
const CDN_BASE = process.env.CDN_BASE_URL || 'https://cdn.example.com/';

module.exports = {
  mode: 'production',
  devtool: 'hidden-source-map',   // generates .map but hides URL from public bundles
  output: {
    path: path.resolve(__dirname, 'dist'),
    filename: 'static/js/[name].[contenthash].js',
    publicPath: CDN_BASE,
    // Webpack now writes:
    // //# sourceMappingURL=https://cdn.example.com/static/js/main.abc123.js.map
  },
};

Use hidden-source-map if you do not want the sourceMappingURL comment exposed in the public bundle (for example, if .map files are uploaded to a private S3 bucket and only accessed by the error tracking SDK using an authenticated URL). Use source-map when DevTools should also resolve the map.

Step 3: Use devtoolModuleFilenameTemplate for module path rewriting

output.publicPath fixes the URL of the .map file itself. The sources array inside the map — which tells DevTools where the original source files live — is controlled separately by output.devtoolModuleFilenameTemplate. By default it uses webpack:// protocol URIs, which DevTools handles fine, but some error tracking SDKs need either absolute https:// URLs or relative paths rooted at a known prefix:

module.exports = {
  output: {
    publicPath: CDN_BASE,
    devtoolModuleFilenameTemplate: (info) => {
      // Produces: webpack:///src/components/Button.tsx
      // Override only if your error tracker requires a different scheme:
      return `webpack:///${path
        .relative(process.cwd(), info.absoluteResourcePath)
        .replace(/\\/g, '/')}`;
    },
  },
};

If you need to use SourceMapDevToolPlugin explicitly (for example, to control publicPath independently of chunk publicPath), you can pass its own publicPath option:

const { SourceMapDevToolPlugin } = require('webpack');

module.exports = {
  devtool: false,   // disable built-in devtool; use plugin directly
  plugins: [
    new SourceMapDevToolPlugin({
      filename: '[file].map',
      publicPath: CDN_BASE,   // only affects the sourceMappingURL comment
      // module: true, columns: true  (defaults)
    }),
  ],
};

This is the most precise option when you want to control sourceMappingURL independently of how chunks load other assets.

Step 4: Inject dynamic webpack_public_path for runtime overrides

In multi-environment pipelines where the CDN URL is not known at build time (for example, preview deployments that generate a unique CDN hostname per PR), inject the public path at runtime before any other Webpack chunk loading occurs. Create a dedicated entry file:

// src/webpack-public-path.js  — must be listed FIRST in entry
__webpack_public_path__ = window.__APP_CONFIG__?.cdnBase || 'https://cdn.example.com/';
// webpack.config.js
module.exports = {
  entry: {
    app: ['./src/webpack-public-path.js', './src/index.js'],
  },
  output: {
    publicPath: 'auto',   // let runtime assignment take over
  },
};

The runtime assignment to __webpack_public_path__ overrides the compile-time value. The server can then inject window.__APP_CONFIG__ into the HTML template at request time with the correct CDN hostname for that deployment.

Step 5: Purge CDN cache and validate with curl

After rebuilding and redeploying, stale cached responses from old builds will still be served until the CDN cache is invalidated. Purge the affected paths using your CDN provider’s API, then confirm the fix:

# CloudFront example: invalidate all JS and map files
aws cloudfront create-invalidation \
  --distribution-id E1EXAMPLE \
  --paths "/static/js/*"

# Cloudflare example via API
curl -X POST "https://api.cloudflare.com/client/v4/zones/$ZONE_ID/purge_cache" \
  -H "Authorization: Bearer $CF_TOKEN" \
  -H "Content-Type: application/json" \
  --data '{"files":["https://cdn.example.com/static/js/main.abc123.js",
                     "https://cdn.example.com/static/js/main.abc123.js.map"]}'

Verification

After cache purge, confirm the full chain: the bundle references a resolvable URL, the map is reachable, and CORS headers are present (required for cross-origin DevTools and SDK fetches):

# 1. Extract the sourceMappingURL from the deployed bundle
BUNDLE="https://cdn.example.com/static/js/main.abc123.js"
MAP_URL=$(curl -s "$BUNDLE" | grep -oP '(?<=sourceMappingURL=)\S+')
echo "Map URL: $MAP_URL"

# 2. Verify the map file is accessible with correct CORS header
curl -I \
  -H "Origin: https://app.example.com" \
  "$MAP_URL"

# Expected output (key headers):
# HTTP/2 200
# content-type: application/json
# access-control-allow-origin: *
# x-cache: Hit from cloudfront

# 3. Validate map integrity locally
npx source-map validate "$MAP_URL"
# Outputs: Mapping OK — 1842 segments across 37 sources

If step 2 returns 403 or is missing access-control-allow-origin, configure your CDN to add CORS headers for .map files (see Edge Cases below).

Edge Cases & Gotchas

  • CORS headers on .map files. DevTools and error tracking SDKs fetch source maps as cross-origin requests. If the CDN does not return Access-Control-Allow-Origin: * (or a specific origin), the fetch is blocked silently — no 404, just a CORS error. Add a CDN response header rule matching *.map requests to inject the header. In Nginx acting as an origin before the CDN: add_header Access-Control-Allow-Origin "*"; scoped to location ~* \.map$.

  • Choosing the right devtool setting matters upstream. A devtool value such as eval or eval-cheap-source-map does not emit external .map files at all — it embeds maps inside eval() strings. These modes are incompatible with CDN hosting because there is no separate file to upload. Refer to Choosing the Right Webpack devtool Setting to select a mode that emits external files before attempting any CDN configuration.

  • Content-hash rotation requires selective invalidation. Each new build produces new content hashes. Purging /* on every deploy is safe but costly. Scope invalidations to the affected file paths, or use a cache-busting query string keyed to the commit SHA if your CDN plan charges per-invalidation.

  • Split chunks and lazy-loaded routes. output.publicPath applies globally, but each lazy-loaded chunk produces its own sourceMappingURL. Verify a representative async chunk (not just the main entry) after the fix. Use curl -s <async-chunk-url> | tail -1 to inspect its comment independently.

  • Source map upload vs. public hosting. Some teams choose to upload .map files directly to the error tracking SDK (Sentry, Datadog) and serve bundles with hidden-source-map so maps are never public. In that workflow the sourceMappingURL comment is intentionally absent from the public bundle, and publicPath only affects chunk loading — not source map resolution. Ensure you understand which model you are using before debugging a 404.

FAQ

Why do source maps resolve correctly in local development but return 404 after CDN deployment? Webpack’s local dev server serves everything from localhost and resolves relative sourceMappingURL paths against the same origin. In production, the HTML page is served from your app origin while assets are on the CDN. The browser resolves the relative map URL against the app origin, which does not hold the file. Setting output.publicPath to an absolute CDN URL writes an absolute sourceMappingURL, so the browser fetches directly from the CDN regardless of where the HTML page was served.

Can I keep publicPath relative and fix the 404 with a Nginx proxy rule instead? Technically yes — you can proxy requests for *.js.map at the app origin to the CDN origin in Nginx. This works but adds latency (the proxy hop), complicates cache invalidation, and means every DevTools request routes through your origin rather than hitting the CDN edge. Fixing publicPath at build time is the correct solution: it eliminates the origin entirely from the map-fetch path and makes the intent explicit in the Webpack configuration.

Do I need to set publicPath differently for Webpack 4 vs. Webpack 5? The output.publicPath option works the same way in both versions. The key Webpack 5 addition is publicPath: 'auto', which instructs Webpack to infer the public path from document.currentScript at runtime. auto does not help with source map paths in all environments because the inference happens per-chunk during chunk loading, not at the point where sourceMappingURL is written. For CDN deployments, set an explicit absolute URL or use __webpack_public_path__ for runtime control rather than relying on auto.