Choosing the Right Webpack devtool Setting
Picking the wrong devtool value is one of the most common reasons a team ends up with either painfully slow builds or stack traces that point to minified gibberish in production. This page walks through every meaningful option, explains the mechanical trade-offs, and gives you a decision procedure grounded in configuring webpack for production source maps — part of the broader source map generation and stack trace debugging reference.
When to Revisit Your devtool Choice
The first sign something is wrong usually shows up in one of four ways: your CI pipeline takes three minutes to rebuild a single changed module; a production error report gives you Object.<anonymous> (main.abc123.js:1:45821) with no human-readable context; a penetration tester flags that your .map files are publicly accessible on the CDN; or a colleague opens the browser DevTools on a staging server and can read raw business logic source code.
Each of these is a symptom of the wrong devtool for the environment. A common misconfiguration looks like this:
// webpack.config.js — same config used everywhere (bad)
module.exports = {
mode: 'production',
devtool: 'eval-source-map', // eval-based options break CSP and expose source
};
This leaks fully-readable source into the browser and breaks any Content-Security-Policy header that includes script-src 'strict-dynamic', because eval is classified as an unsafe inline execution context.
Root Cause Explanation
Each devtool value maps to a different code-generation strategy inside webpack’s SourceMapDevToolPlugin (or its internal equivalent). Understanding the mechanics prevents cargo-cult configuration.
eval — webpack wraps every module’s compiled output in an eval() call and appends a //# sourceURL=webpack://... comment. No separate .map file is written. The browser DevTools use the sourceURL hint to label the evaluated string, giving you module-level identification but no line or column mapping. Rebuilds are extremely fast because the eval string is cached per module. Incompatible with strict-dynamic CSP.
eval-source-map — similar to eval, but the full source map is Base64-encoded and inlined inside the eval string as a //# sourceMappingURL=data:... comment. Accurate line and column info in DevTools, but still breaks CSP and produces large bundle files. Fine for local HMR loops, never for production.
cheap-source-map — writes a real external .map file. Generates line-level mappings only (column numbers are omitted), and it ignores any source maps produced by loaders (e.g., Babel). Fast enough for most CI scenarios but imprecise when Babel has rewritten async functions or class fields.
cheap-module-source-map — identical to cheap-source-map except loader-produced source maps are merged into the final map. This is the key difference: when Babel transpiles your async/await syntax, cheap-module-source-map traces through Babel’s output map to land on the original TypeScript or ES2022 line. The recommended option for day-to-day development.
source-map — writes a full external .map file with both line and column mappings, incorporating every loader’s source map. The slowest rebuild option. webpack appends //# sourceMappingURL=bundle.js.map to the output bundle, so any browser or CDN that serves the .js file will automatically resolve the map if it exists at the referenced path. This is the root cause of accidental source exposure in production.
hidden-source-map — mechanically identical to source-map in what it writes to disk, but the //# sourceMappingURL= comment is omitted from the bundle. The browser cannot find the map automatically. You must upload the .map file explicitly to your error tracker (Sentry, Datadog, etc.) and then keep it off your public CDN. This is the correct choice for production.
nosources-source-map — writes an external .map with the full position mappings intact but strips the sourcesContent field. An error tracker can resolve a minified stack frame to a filename and line number, but it cannot display the original source code inline. Useful when regulations prevent sending source to a third party.
The structural difference between source-map and hidden-source-map is a single omitted comment in the bundle:
// With source-map — browser auto-fetches the map:
(()=>{var o=1})();
//# sourceMappingURL=main.abc123.js.map
// With hidden-source-map — no auto-fetch, map must be uploaded manually:
(()=>{var o=1})();
Step-by-Step Selection Guide
Step 1: Identify your environment constraints
# Determine which of these applies to your build target:
# - local development (DX speed matters, source exposure is fine)
# - CI / integration testing (accuracy matters, speed matters)
# - staging (production-equivalent config, source exposure is a risk)
# - production (source must never be publicly accessible)
echo $NODE_ENV # should be set per environment by your CI/CD system
Step 2: Select the devtool value and write the webpack config snippet
// webpack.dev.js
module.exports = {
mode: 'development',
devtool: 'cheap-module-source-map', // fast rebuilds + loader-aware line mapping
};
// webpack.prod.js
module.exports = {
mode: 'production',
devtool: 'hidden-source-map', // full accuracy, no sourceMappingURL comment
};
Step 3: Measure build time impact with webpack --profile
npx webpack --config webpack.prod.js --profile --json > stats.json
# Then inspect the longest-running modules:
node -e "
const s = require('./stats.json');
const mods = s.modules || [];
mods.sort((a,b) => b.buildTime - a.buildTime);
mods.slice(0,10).forEach(m => console.log(m.buildTime + 'ms', m.name));
"
Step 4: Verify stack trace accuracy for your chosen setting
// Throw a deliberate error in a deeply-nested async function and check
// the reported line number against your original source.
async function checkMappingDepth() {
await Promise.resolve();
throw new Error('mapping-probe'); // <-- note the line number in the error tracker
}
checkMappingDepth().catch(err => console.error(err.stack));
After deploying with hidden-source-map, upload the .map files to your error tracker and confirm the symbolicated frame points to the correct original line. If it does not, verify the --source-root and publicPath settings match what the tracker expects — see fixing incorrect source map paths after CDN deployment for that specific failure mode.
Step 5: Lock the setting per environment using webpack-merge
// webpack.common.js
const path = require('path');
module.exports = {
entry: './src/index.js',
output: {
filename: '[name].[contenthash].js',
path: path.resolve(__dirname, 'dist'),
clean: true,
},
};
// webpack.dev.js
const { merge } = require('webpack-merge');
const common = require('./webpack.common.js');
module.exports = merge(common, {
mode: 'development',
devtool: 'cheap-module-source-map',
devServer: { hot: true },
});
// webpack.prod.js
const { merge } = require('webpack-merge');
const common = require('./webpack.common.js');
module.exports = merge(common, {
mode: 'production',
devtool: 'hidden-source-map',
});
webpack-merge deep-merges configuration objects so the devtool key in the environment-specific file overrides any default, and the output settings from the common config are preserved without duplication.
Build Speed and Accuracy Reference Tables
| devtool | Initial build | Rebuild | Accuracy | Source exposed | Production safe |
|---|---|---|---|---|---|
eval |
Fastest | Fastest | Module-level only | In eval strings | No (CSP) |
eval-source-map |
Slow | Fast | Full (line + col) | In eval strings | No (CSP) |
cheap-source-map |
Fast | Fast | Line only, no loaders | External .map | If .map is private |
cheap-module-source-map |
Medium | Fast | Line only, loader-aware | External .map | If .map is private |
source-map |
Slow | Slow | Full (line + col) | External .map + URL comment | Risky (auto-fetched) |
hidden-source-map |
Slow | Slow | Full (line + col) | External .map, no comment | Yes (with upload) |
nosources-source-map |
Slow | Slow | Line + col, no source text | External .map, no content | Yes |
| Environment | Recommended devtool | Reason |
|---|---|---|
| Local development | cheap-module-source-map |
Fast HMR, loader-aware, acceptable accuracy |
| Unit/integration CI | cheap-module-source-map |
Same as dev; speed matters in CI |
| Staging | hidden-source-map |
Matches production config, validates upload workflow |
| Production | hidden-source-map |
Full accuracy, no public exposure, requires tracker upload |
| Regulated environments | nosources-source-map |
Stack frame resolution without transmitting source text |
Verification
After building with hidden-source-map, confirm the map file exists and the bundle contains no sourceMappingURL comment, then check bundle composition with source-map-explorer:
# 1. Confirm no sourceMappingURL in the bundle
grep -c "sourceMappingURL" dist/main.*.js
# Expected output: 0
# 2. Confirm the .map file was generated
ls -lh dist/*.map
# Expected: dist/main.abc123.js.map (present, but should not be deployed)
# 3. Analyze what is inside the bundle using source-map-explorer
npx source-map-explorer dist/main.*.js dist/main.*.js.map --html dist/report.html
open dist/report.html
# 4. Parse webpack --profile output for the source-map plugin cost
npx webpack --config webpack.prod.js --profile --json \
| node -e "
const s = JSON.parse(require('fs').readFileSync('/dev/stdin','utf8'));
console.log('Total build time:', s.time, 'ms');
(s.chunks || []).forEach(c =>
console.log('Chunk:', c.id, '| files:', c.files.join(', '))
);
"
Edge Cases & Gotchas
-
devtool: falsewithSourceMapDevToolPlugindirectly — settingdevtool: falsedisables the automatic shorthand and lets you configureSourceMapDevToolPluginmanually. This is the only way to write maps to a different output directory, set a custompublicPathpointing to a private storage bucket, or exclude specific chunks from map generation. If you setdevtoolto any string value AND also instantiateSourceMapDevToolPlugin, webpack will generate source maps twice and your build output will be incorrect. -
evaland Content Security Policy — anyeval-based devtool (eval,eval-source-map,eval-cheap-source-map) requires'unsafe-eval'in thescript-srcCSP directive. Most security-hardened deployments forbid this. If your app ships a CSP header,evalvariants will silently break in browsers enforcing the policy and you will see a console error likeRefused to evaluate a string as JavaScript because 'unsafe-eval' is not an allowed source of script. -
hidden-source-maprequires an explicit upload step — because the browser never fetches the map automatically, your CI pipeline must upload*.mapfiles to your error tracker before deleting them from the build artifact. If the upload step is missing or runs after deployment, symbolication will fail silently. Add a post-build script or use@sentry/webpack-plugin/ the Datadog source map CLI, and gate the deployment on a successful upload exit code. -
nosources-source-mapand Sentry compatibility — Sentry can resolve frame positions from anosourcesmap (because the mappings array is intact), but the “View Source” feature in the issue detail UI will show nothing. If your workflow depends on Sentry’s inline source view for triage, usehidden-source-mapwith maps stored in Sentry’s artifact bundle instead.
FAQ
Why does cheap-module-source-map show wrong line numbers for Babel-transpiled async functions?
cheap-module-source-map maps lines but not columns. When Babel rewrites async/await to a generator state machine, the generated output for one original line can span many output lines. Line-only mapping still points you to the correct source line in most cases — if you see consistent offsets, check whether @babel/plugin-transform-async-to-generator is active and consider enabling retainLines: true in your Babel config for development builds only. Also check why source maps break after Babel transpilation for a deeper diagnosis.
Can I use hidden-source-map in development to exactly mirror production?
You can, but rebuilds will be noticeably slower because webpack must write a full column-accurate map on every change. Most teams accept this for staging (where maps are validated end-to-end) but use cheap-module-source-map locally for speed. A reasonable middle ground is to add a FORCE_FULL_SOURCEMAP=1 environment variable check in your webpack config that switches devtool to hidden-source-map on demand without changing the default development path.
Does nosources-source-map reduce bundle size?
The .map file itself becomes smaller because the sourcesContent array (which stores the full text of every original file) is omitted. The deployed JavaScript bundle is identical in size to hidden-source-map because the bundle never contained source content to begin with. The saving is only in the artifact you upload to your error tracker, which can be meaningful for large codebases with many small modules.
Related
- Configuring webpack for production source maps — parent guide covering the full production source map workflow
- Source map generation and stack trace debugging — broader reference on source maps across bundlers, browsers, and error trackers
- Fixing incorrect source map paths after CDN deployment — what to do when your error tracker resolves to the wrong file path
- Why source maps break after Babel transpilation — diagnosing offset errors caused by Babel’s code transformations