Source Maps: Configuration and Debugging
A practical guide to source maps covering webpack and Vite configuration, production security, error tracking integration, and debugging workflows in DevTools and VS Code.
Source Maps: Configuration and Debugging
Overview
Source maps solve a fundamental problem in modern JavaScript development: the code you write is not the code the browser runs. Between transpilation, bundling, minification, and tree-shaking, your carefully structured modules become an unreadable wall of single-letter variables and concatenated functions. When an error occurs at line 1, column 48293 of app.min.js, you need a way to trace it back to UserService.js line 47. That is what source maps do.
I have spent years configuring source maps across dozens of production applications, and the number of teams that get this wrong is staggering. Either they ship source maps publicly (exposing their entire codebase), skip them entirely in production (making error tracking useless), or use the wrong devtool setting in webpack (destroying rebuild performance during development). This article covers the full picture: how source maps work internally, how to configure them properly for both development and production, how to integrate them with error tracking services, and how to lock them down in production.
How Source Maps Work
A source map is a JSON file that contains a bidirectional mapping between positions in a generated file and positions in the original source files. When Chrome DevTools or an error tracking service encounters a .map file, it reconstructs the original source and maps stack traces back to your actual code.
The Mappings Format
A source map file follows the Source Map v3 specification. Here is a simplified example:
{
"version": 3,
"file": "app.min.js",
"sources": ["src/utils.js", "src/app.js"],
"sourcesContent": ["var add = function(a, b)...", "var app = require..."],
"names": ["add", "multiply", "init"],
"mappings": "AAAA,SAASA,IAAIC;AACX,OAAOD..."
}
The sources array lists every original file that contributed to the output. The sourcesContent array optionally embeds the full original source (so DevTools can display it without fetching separate files). The names array holds original identifiers that were renamed during minification. The mappings string is where the real work happens.
VLQ Encoding
The mappings field uses Base64 Variable-Length Quantity (VLQ) encoding to compress position data. Each segment in the mappings string encodes up to five values: the column in the generated file, the index into the sources array, the line in the original source, the column in the original source, and optionally an index into the names array.
Semicolons separate lines in the generated file. Commas separate segments within a line. Each segment is a sequence of Base64 characters encoding VLQ-encoded integers. The values are relative (deltas from the previous segment), which keeps the numbers small and the encoding compact.
For a 500KB minified bundle, an uncompressed mapping of every character position would be enormous. VLQ encoding with relative offsets typically produces a source map that is 30-50% the size of the generated file. With gzip compression, source maps shrink to roughly 10-15% of the generated file size.
You never need to decode VLQ by hand. But understanding that these mappings are delta-encoded explains why corrupted or truncated source maps produce wildly incorrect line numbers rather than simply failing -- the decoder keeps adding deltas from the corruption point forward.
Source Map Types
External Source Maps
The most common type. The bundler generates a separate .map file alongside the output. The generated file contains a special comment pointing to the map:
//# sourceMappingURL=app.min.js.map
External maps are the default for production because you can control access to the .map files separately from the JavaScript files.
Inline Source Maps
The entire source map is embedded as a Base64 data URL directly in the generated file:
//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLC...
Inline maps are convenient during development because there is only one file to serve, but they bloat the output file enormously. A 200KB bundle with an inline source map might balloon to 800KB. Never use inline source maps in production.
Hidden Source Maps
The bundler generates the .map file but does not add the sourceMappingURL comment to the generated file. Browsers and DevTools cannot discover the map automatically. This is the approach you want for production error tracking: upload the maps to Sentry or Datadog, but never expose them to end users.
Webpack Devtool Options
Webpack's devtool setting controls source map generation. There are over a dozen options, and choosing the wrong one either kills your rebuild speed in development or exposes your source in production. Here is a practical comparison of the options that actually matter:
| devtool | Build Speed | Rebuild Speed | Quality | Use Case |
|---|---|---|---|---|
eval |
Fast | Fastest | Generated code only | Quick iteration, no original source |
eval-source-map |
Slow | Fast | Original source | Development (recommended) |
eval-cheap-module-source-map |
Medium | Fast | Original lines only | Development (large projects) |
cheap-module-source-map |
Medium | Medium | Original lines only | Development with HMR issues |
source-map |
Slowest | Slowest | Original source | Production (full fidelity) |
hidden-source-map |
Slowest | Slowest | Original source | Production with error tracking |
nosources-source-map |
Slowest | Slowest | Line/column only | Production (no source content) |
For development, use eval-source-map. The eval wrapper allows fast rebuilds because webpack only re-evaluates the changed module rather than regenerating the entire source map. You get accurate original source in DevTools, and rebuild times stay under a second even for large projects.
For production, use hidden-source-map. This generates full-fidelity source maps without embedding the sourceMappingURL comment. You upload the maps to your error tracking service and keep them off your public servers.
// webpack.config.js
var path = require("path");
module.exports = function(env) {
var isProduction = env && env.production;
return {
mode: isProduction ? "production" : "development",
devtool: isProduction ? "hidden-source-map" : "eval-source-map",
entry: "./src/index.js",
output: {
filename: isProduction ? "[name].[contenthash].js" : "[name].js",
path: path.resolve(__dirname, "dist")
},
module: {
rules: [
{
test: /\.js$/,
exclude: /node_modules/,
use: {
loader: "babel-loader",
options: {
presets: ["@babel/preset-env"],
sourceMaps: true
}
}
},
{
test: /\.css$/,
use: [
"style-loader",
{
loader: "css-loader",
options: { sourceMap: true }
}
]
}
]
}
};
};
Note that Babel, CSS loaders, and other tools in the pipeline each have their own sourceMap option. If any link in the chain skips source map generation, the final map will be incomplete or inaccurate. Always enable source maps at every stage.
Vite Source Map Configuration
Vite uses Rollup under the hood and exposes a simpler source map configuration:
// vite.config.js
var defineConfig = require("vite").defineConfig;
module.exports = defineConfig({
build: {
sourcemap: true, // true | false | "inline" | "hidden"
rollupOptions: {
output: {
sourcemapExcludeSources: false // include sourcesContent
}
}
},
css: {
devSourcemap: true // CSS source maps in development
}
});
The sourcemap option accepts four values: true generates external maps with the sourceMappingURL comment, "inline" embeds maps as data URLs, "hidden" generates external maps without the comment, and false skips generation entirely.
For production builds with Sentry or similar services, use "hidden":
module.exports = defineConfig({
build: {
sourcemap: "hidden"
}
});
CSS Source Maps
CSS goes through its own transformation pipeline -- Sass/Less compilation, PostCSS processing, autoprefixing, minification. Each step can produce source maps that chain together.
// webpack.config.js -- CSS source map chain
module.exports = {
devtool: "source-map",
module: {
rules: [
{
test: /\.scss$/,
use: [
{ loader: "style-loader" },
{ loader: "css-loader", options: { sourceMap: true } },
{ loader: "postcss-loader", options: { sourceMap: true } },
{ loader: "sass-loader", options: { sourceMap: true } }
]
}
]
}
};
Every loader in the chain must have sourceMap: true. If postcss-loader drops source maps, css-loader cannot reconstruct the mapping back to the original .scss file. The result is source maps that point to the PostCSS output instead of the Sass source -- which is marginally better than nothing but still not useful.
Multi-Level Source Maps
TypeScript projects face a multi-step pipeline: TypeScript compiles to JavaScript, then webpack or another bundler processes the JavaScript output. Each step generates its own source map. Webpack handles this automatically through its module source map variants.
The key is devtool: "eval-cheap-module-source-map" (development) or devtool: "hidden-source-map" (production). The module keyword tells webpack to process source maps from loaders (like ts-loader) and compose them with webpack's own source map. Without module, the source map points to the TypeScript compiler output (transpiled JavaScript) rather than the original .ts files.
// webpack.config.js -- TypeScript multi-level source maps
module.exports = {
devtool: "hidden-source-map",
module: {
rules: [
{
test: /\.ts$/,
use: {
loader: "ts-loader",
options: {
compilerOptions: {
sourceMap: true,
inlineSources: true // embed TS source in the map
}
}
}
}
]
}
};
Setting inlineSources: true in tsconfig.json embeds the original TypeScript source in the source map's sourcesContent field. This is critical for error tracking services -- without it, Sentry can show you the correct file and line number but cannot display the actual source code.
Source Maps in Production
Security Considerations
Source maps contain your entire original source code, complete with comments, variable names, and file paths. Publishing them alongside your production bundle is equivalent to shipping unminified code. Competitors can read your business logic. Attackers can study your validation and authentication code. Internal paths might reveal infrastructure details.
There are three strategies for handling source maps in production:
Strategy 1: Do not generate them. Simple but eliminates production debugging. I do not recommend this for any application that needs error tracking.
Strategy 2: Generate hidden source maps. Use hidden-source-map in webpack or sourcemap: "hidden" in Vite. Upload the maps to your error tracking service. Do not deploy them to your web servers at all.
Strategy 3: Generate and deploy, but restrict access. Deploy the .map files to your servers but use Nginx or your CDN to block public access. This lets internal developers load source maps manually in DevTools while keeping them away from the public.
Strategy 2 is the safest and what I recommend for most teams. Strategy 3 is useful when you need on-demand debugging access without going through an error tracking service.
Sentry Integration
Sentry is the most widely used error tracking service that supports source map uploads. The workflow is: build your application with hidden source maps, upload the maps to Sentry with a release identifier, and configure Sentry in your client code to tag errors with the same release.
// sentry-setup.js -- Client-side Sentry initialization
var Sentry = require("@sentry/browser");
Sentry.init({
dsn: "https://[email protected]/0",
release: process.env.SENTRY_RELEASE || "1.0.0",
environment: process.env.NODE_ENV || "production",
integrations: [
Sentry.browserTracingIntegration()
],
tracesSampleRate: 0.1
});
Upload source maps as part of your build pipeline:
#!/bin/bash
# upload-sourcemaps.sh -- Upload source maps to Sentry after build
RELEASE_VERSION=$(node -e "console.log(require('./package.json').version)")
# Build with hidden source maps
npx webpack --env production
# Create a Sentry release
npx sentry-cli releases new "$RELEASE_VERSION"
# Upload source maps
npx sentry-cli releases files "$RELEASE_VERSION" upload-sourcemaps \
./dist \
--url-prefix "~/static/js" \
--rewrite
# Finalize the release
npx sentry-cli releases finalize "$RELEASE_VERSION"
# Clean up -- remove source maps from the deploy directory
find ./dist -name "*.map" -delete
The --url-prefix flag is critical and the source of most Sentry source map failures. It must match the URL path where your JavaScript files are served. If your bundle is served from https://example.com/static/js/app.abc123.js, the prefix is ~/static/js. The ~ is a Sentry convention meaning "any host."
The --rewrite flag rewrites the sourceMappingURL references in your source maps to use Sentry's internal format. This is necessary when the generated sourceMappingURL uses a relative path that would not resolve on Sentry's servers.
After uploading, delete the .map files from your deploy directory. They should never reach your production servers.
Webpack Plugin for Sentry
For tighter integration, use the Sentry webpack plugin instead of a post-build script:
// webpack.config.js -- Sentry webpack plugin
var SentryWebpackPlugin = require("@sentry/webpack-plugin");
var pkg = require("./package.json");
module.exports = {
devtool: "hidden-source-map",
plugins: [
new SentryWebpackPlugin({
org: "my-org",
project: "my-project",
authToken: process.env.SENTRY_AUTH_TOKEN,
release: {
name: pkg.version
},
sourcemaps: {
assets: "./dist/**",
filesToDeleteAfterUpload: "./dist/**/*.map"
}
})
]
};
The plugin automatically uploads source maps during the build, tags the release, and deletes the .map files from the output directory. One less script to maintain.
Bundle Analysis with source-map-explorer
The source-map-explorer tool uses source maps to visualize exactly which modules contribute to your bundle size. Unlike webpack-bundle-analyzer (which uses webpack's internal stats), source-map-explorer works with any bundler that produces source maps.
# Install and run
npm install -g source-map-explorer
# Analyze a single bundle
source-map-explorer dist/app.min.js
# Analyze multiple chunks
source-map-explorer dist/*.js
# Export as HTML
source-map-explorer dist/app.min.js --html result.html
# JSON output for CI integration
source-map-explorer dist/app.min.js --json result.json
For this to work, your production build must generate source maps. If you use hidden-source-map, run the analysis before deleting the .map files. I add a analyze script to package.json that builds with full source maps and opens the explorer:
{
"scripts": {
"analyze": "webpack --env production && source-map-explorer dist/*.js"
}
}
Debugging with Chrome DevTools
Chrome DevTools automatically loads source maps when it finds a sourceMappingURL comment. Open DevTools, go to the Sources panel, and your original files appear in the file tree under a section labeled with your webpack devServer or file path.
Key DevTools Features
Breakpoints in original source: Set breakpoints in your .ts, .jsx, or .scss files. DevTools maps them to the correct position in the generated code.
Blackboxing: Right-click a source map-resolved file and choose "Add script to ignore list" to skip library code when stepping through. This is especially useful for stepping over React internals or lodash utility functions.
Conditional breakpoints: Right-click a line number, choose "Add conditional breakpoint," and enter a JavaScript expression. The expression is evaluated in the context of the generated code, but the variables visible in the Scope panel are the original names from the source map.
Manual source map loading: If you use hidden source maps, you can still load them manually. Right-click the generated file in the Sources panel, choose "Add source map," and enter the URL or file path to the .map file. This is useful for debugging production issues when you have the map file locally but have not deployed it.
Debugging with VS Code
VS Code's JavaScript debugger supports source maps natively. Configure a launch configuration that points to your dev server or build output:
{
"version": "0.2.0",
"configurations": [
{
"type": "chrome",
"request": "launch",
"name": "Debug Application",
"url": "http://localhost:8080",
"webRoot": "${workspaceFolder}/src",
"sourceMaps": true,
"sourceMapPathOverrides": {
"webpack:///src/*": "${webRoot}/*",
"webpack:///./src/*": "${webRoot}/*",
"webpack:///./*": "${workspaceFolder}/*"
}
}
]
}
The sourceMapPathOverrides setting is where most VS Code source map issues originate. Webpack source maps use webpack:/// prefixed paths. These paths must be mapped to actual file paths on disk. The default overrides handle common cases, but if your project structure is non-standard, you need to adjust them.
Diagnostic tip: Set "trace": true in your launch configuration to enable verbose logging. VS Code writes detailed source map resolution logs to the Debug Console, showing exactly which paths it tried and why a mapping failed.
Nginx Access Control for Source Maps
If you deploy source maps to your servers (Strategy 3), restrict access with Nginx:
# nginx.conf -- Restrict source map access
server {
listen 443 ssl;
server_name example.com;
# Serve static assets normally
location /static/ {
alias /var/www/app/dist/;
# Block public access to source maps
location ~* \.map$ {
# Option A: Restrict to internal IPs
allow 10.0.0.0/8;
allow 172.16.0.0/12;
allow 192.168.0.0/16;
deny all;
# Option B: Require authentication header
# if ($http_x_sourcemap_token != "your-secret-token") {
# return 403;
# }
}
}
}
With Option A, only requests from internal network ranges can access .map files. Developers on the VPN get source maps in DevTools; everyone else gets a 403.
SourceMap HTTP Header
Instead of embedding the sourceMappingURL comment in your JavaScript files, you can use the SourceMap HTTP response header. This lets Nginx add the mapping only for authorized requests:
location /static/ {
alias /var/www/app/dist/;
# Add SourceMap header only for internal IPs
location ~* \.js$ {
set $source_map_header "";
if ($remote_addr ~* "^(10\.|172\.(1[6-9]|2[0-9]|3[01])\.|192\.168\.)") {
set $source_map_header "${uri}.map";
}
add_header SourceMap $source_map_header;
}
}
This approach means the JavaScript files have no sourceMappingURL comment at all. External users see minified code with no indication that source maps exist. Internal users get the SourceMap header, and their DevTools automatically fetch and apply the maps.
Source Maps and CDN Caching
If you serve assets through a CDN like CloudFront or Fastly, source maps interact with caching in two ways.
First, the CDN might cache .map files and serve them publicly even if your origin restricts access. Configure your CDN to either exclude .map files from caching or respect the origin's Cache-Control: private header.
Second, if you use the SourceMap HTTP header approach and the header varies based on the client IP, you must configure the CDN to not cache the SourceMap header -- or better, strip the header at the CDN edge for non-internal requests. With CloudFront, you can achieve this through a Lambda@Edge function:
// cloudfront-strip-sourcemap.js -- Lambda@Edge origin response function
exports.handler = function(event, context, callback) {
var response = event.Records[0].cf.response;
var headers = response.headers;
// Remove SourceMap header before caching at the edge
if (headers["sourcemap"]) {
delete headers["sourcemap"];
}
callback(null, response);
};
This strips the SourceMap header from cached responses. Internal developers can still access source maps directly by URL (which hits the origin), but the CDN never reveals their existence through headers.
Complete Working Example
Here is a full project setup with webpack configured for development and production source maps, Sentry integration, and Nginx access control.
Project Structure
my-app/
src/
index.js
utils.js
webpack.config.js
package.json
sentry-upload.sh
nginx/
app.conf
webpack.config.js
// webpack.config.js
var path = require("path");
var SentryWebpackPlugin = require("@sentry/webpack-plugin");
var pkg = require("./package.json");
module.exports = function(env) {
var isProduction = env && env.production;
var plugins = [];
if (isProduction && process.env.SENTRY_AUTH_TOKEN) {
plugins.push(
new SentryWebpackPlugin({
org: process.env.SENTRY_ORG,
project: process.env.SENTRY_PROJECT,
authToken: process.env.SENTRY_AUTH_TOKEN,
release: {
name: pkg.version
},
sourcemaps: {
assets: path.resolve(__dirname, "dist", "**"),
filesToDeleteAfterUpload: path.resolve(__dirname, "dist", "**/*.map")
}
})
);
}
return {
mode: isProduction ? "production" : "development",
devtool: isProduction ? "hidden-source-map" : "eval-source-map",
entry: "./src/index.js",
output: {
filename: isProduction ? "[name].[contenthash:8].js" : "[name].js",
path: path.resolve(__dirname, "dist"),
clean: true
},
module: {
rules: [
{
test: /\.js$/,
exclude: /node_modules/,
use: {
loader: "babel-loader",
options: {
presets: ["@babel/preset-env"],
sourceMaps: true
}
}
},
{
test: /\.css$/,
use: [
"style-loader",
{ loader: "css-loader", options: { sourceMap: true } },
{ loader: "postcss-loader", options: { sourceMap: true } }
]
}
]
},
devServer: {
static: path.resolve(__dirname, "dist"),
port: 8080,
hot: true
},
plugins: plugins
};
};
src/index.js
// src/index.js
var Sentry = require("@sentry/browser");
var utils = require("./utils");
Sentry.init({
dsn: "https://[email protected]/0",
release: "1.0.0",
environment: process.env.NODE_ENV || "production"
});
function init() {
try {
var result = utils.processData({ items: [1, 2, 3] });
document.getElementById("output").textContent = JSON.stringify(result);
} catch (err) {
Sentry.captureException(err);
console.error("Application error:", err);
}
}
document.addEventListener("DOMContentLoaded", init);
Nginx Configuration
# nginx/app.conf
server {
listen 443 ssl;
server_name example.com;
root /var/www/app;
# Static assets with long cache
location /static/ {
alias /var/www/app/dist/;
expires 1y;
add_header Cache-Control "public, immutable";
# Block source maps for external users
location ~* \.map$ {
internal; # only accessible via internal redirects
allow 10.0.0.0/8;
deny all;
}
}
# Application routes
location / {
proxy_pass http://127.0.0.1:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}
Build and Deploy Script
#!/bin/bash
# deploy.sh -- Build, upload source maps, deploy
set -e
RELEASE=$(node -e "console.log(require('./package.json').version)")
export SENTRY_RELEASE="$RELEASE"
echo "Building release $RELEASE..."
npx webpack --env production
echo "Verifying source maps were deleted after Sentry upload..."
MAP_COUNT=$(find ./dist -name "*.map" | wc -l)
if [ "$MAP_COUNT" -gt 0 ]; then
echo "WARNING: $MAP_COUNT source map files still in dist/. Deleting..."
find ./dist -name "*.map" -delete
fi
echo "Deploying to production..."
rsync -avz --delete ./dist/ deploy@server:/var/www/app/dist/
echo "Deploy complete."
Common Issues and Troubleshooting
1. Sentry shows minified stack traces despite uploading source maps. The most common cause is a --url-prefix mismatch. The prefix you pass to sentry-cli must exactly match the URL path in the browser. Open your browser DevTools Network tab, find the JavaScript file URL, and extract the path. If the file loads from https://cdn.example.com/assets/js/app.abc123.js, your prefix is ~/assets/js. Also verify that the release name in your Sentry.init() call matches the release name used during upload.
2. Chrome DevTools shows source maps but breakpoints do not hit. This usually means the source map paths do not resolve to actual files. Open the Sources panel, find the mapped source file, and check if its content is (source not available). If so, the sourcesContent field is missing from the source map and DevTools cannot fetch the original file from the path in the sources array. Fix this by enabling inlineSources in your TypeScript config or ensuring your bundler embeds sourcesContent.
3. VS Code breakpoints appear greyed out with "Breakpoint set but not yet bound." Check the sourceMapPathOverrides in your launch configuration. Enable "trace": true and look for path resolution failures in the Debug Console. Common fixes: adjust the webRoot setting to match your source directory, or add a custom override entry like "webpack:///./~/*": "${workspaceFolder}/node_modules/*" for dependencies.
4. Source maps are correct locally but broken in CI builds. CI environments often have different absolute paths than developer machines. Source maps may contain absolute paths from the CI runner (like /home/runner/work/my-app/src/index.js). Use webpack's output.devtoolModuleFilenameTemplate to normalize paths:
output: {
devtoolModuleFilenameTemplate: function(info) {
return "webpack:///" + info.resourcePath;
}
}
5. Source maps double the build time. Full source-map devtool in development is the usual culprit. Switch to eval-source-map for development. If even that is slow on very large projects (2000+ modules), try eval-cheap-module-source-map -- you lose column-level accuracy but keep original source line mapping, and build times drop significantly.
6. CDN serves stale source maps after a deploy. Content-hashed filenames solve this for JavaScript bundles, but if your source map filenames are not also hashed (or if the hash is only in the JS filename, not the map), the CDN might serve an old map for a new bundle. Ensure your webpack output uses [contenthash] in the filename, and source maps inherit the same hash: app.a1b2c3.js maps to app.a1b2c3.js.map.
Best Practices
Use
eval-source-mapin development andhidden-source-mapin production. This gives you the best balance of rebuild speed during development and full-fidelity maps for error tracking without exposing source code.Enable source maps at every stage of the pipeline. If Babel, TypeScript, Sass, PostCSS, or any other tool in the chain does not produce source maps, the final mapping will be incomplete. Check every loader and plugin configuration.
Always embed
sourcesContentin production source maps. Without embedded source content, error tracking services can show file names and line numbers but cannot display the actual code. UseinlineSources: truein TypeScript and ensure your bundler preservessourcesContent.Delete source maps from your deploy directory after uploading to error tracking. Source maps should exist in exactly two places: your CI build artifacts (for debugging build issues) and your error tracking service. They should not exist on your production servers.
Use content-hashed filenames for both bundles and source maps. This prevents cache invalidation issues and ensures that every bundle version maps to the correct source map. Never deploy
app.jsandapp.js.mapwithout cache-busting filenames.Verify source map uploads in CI. Add a step to your CI pipeline that checks the Sentry API (or your error tracking service's API) to confirm the source maps were uploaded for the current release. A silent upload failure means your next production error will show minified traces.
Run
source-map-explorerin CI to catch bundle size regressions. Export the analysis as JSON and compare against a baseline. Fail the build if any chunk exceeds a size threshold. Source maps make this analysis possible without maintaining separate webpack stats configurations.Normalize source map paths for CI compatibility. Use
devtoolModuleFilenameTemplateto strip machine-specific absolute paths. This ensures source maps are portable across developer machines and CI environments.
References
- Source Map Specification (v3) -- The official specification for the Source Map v3 format
- webpack devtool Documentation -- Complete reference for all webpack devtool options
- Vite Build Options -- Vite source map configuration
- Sentry Source Maps Documentation -- Uploading and troubleshooting source maps with Sentry
- Chrome DevTools Source Maps -- Using source maps in Chrome DevTools
- source-map-explorer -- Bundle analysis tool using source maps
- VS Code JavaScript Debugging -- Configuring source maps for VS Code debugging