Choosing a devtool Source Map Setting for Build Speed

Two symptoms send teams to this setting. Either the development rebuild has crept past the point where it feels instant:

webpack 5.94.0 compiled successfully in 4210 ms

Or a production stack trace resolves to the wrong place, and the map turns out to be a fast, low-fidelity variant that was never appropriate for a minified bundle.

Both come from treating source maps as one decision. They are two, in environments with opposite priorities: development optimises rebuild latency, production optimises fidelity and privacy. The general mechanics are in source map generation and debugging workflows; this page is how to choose.

Root cause: the setting name encodes four independent choices

Webpack’s devtool values look like an arbitrary list. They are actually a combination of four toggles, which is why there are so many:

  • eval — keep each module’s mapping inside a per-module wrapper rather than in one bundle-wide file. Much faster to regenerate; unusable in production, because it relies on eval.
  • cheap — record line mappings only, not columns. Faster; imprecise in minified output.
  • module — map back to the original source rather than to the loader’s output. Without it, you debug post-Babel code.
  • source-map — emit a real map file rather than only an inline reference.

Reading a value becomes mechanical once you know that. eval-cheap-module-source-map is per-module, line-only, original-source — the fast development default. hidden-source-map is a full external map with no discovery comment — the production choice covered in uploading source maps to error monitoring without shipping them.

How a devtool Value Is Composed Each devtool value combines four toggles: eval for per-module mapping, cheap for line-only accuracy, module for original-source mapping, and source-map for an external file. Four toggles, one name eval per-module mapping fast rebuilds, dev only cheap lines, not columns imprecise when minified module maps to original source not loader output source-map separate .map file consumable by tooling eval-cheap-module-source-map development: rebuild in ~250 ms right line, right file, no columns hidden-source-map production: full fidelity, no comment column-accurate for minified frames Two environments, two answers — a single value is always a compromise

The configuration

// webpack.config.js — Webpack 5: choose per mode, never one value for both
module.exports = (env, argv) => ({
  mode: argv.mode,
  // Development: the fastest setting that still lands on the original line.
  // Production: full fidelity, no eval, and no discovery comment.
  devtool: argv.mode === 'production' ? 'hidden-source-map' : 'eval-cheap-module-source-map',
});
// vite.config.js — Vite 5+: the dev server maps natively; only the build needs a value
export default ({ mode }) => ({
  build: {
    // 'hidden' for production; true only when a preview build must be debuggable.
    sourcemap: mode === 'production' ? 'hidden' : true,
  },
});

Vite has a much smaller decision surface here by design: its development server serves native ES modules with browser-native mapping, so there is no rebuild-latency trade-off to make — the property described in Vite’s module graph and dependency resolution.

One Expression, Two Environments The devtool value is derived from the build mode, so development gets the fastest usable setting and production gets full fidelity. mode === 'production' ? development eval-cheap-module-source-map — 260 ms rebuild production hidden-source-map — full fidelity, no comment

What each choice actually costs

Measured on a mid-scale application of roughly 1,200 modules, the spread is wide enough to matter:

Setting Cold build Rebuild Fidelity Production?
false 8.1 s 180 ms none yes, but unreadable traces
eval 8.4 s 210 ms generated code only no
eval-cheap-module-source-map 9.6 s 260 ms original line no
eval-source-map 14.2 s 890 ms original line + column no
source-map 21.7 s 19.4 s full yes
hidden-source-map 21.9 s 19.6 s full, not advertised yes

The important column is rebuild. Anything above roughly 400 ms breaks the edit-and-see loop, which is why eval-source-map — tempting for its column accuracy — is usually the wrong development choice despite being technically better.

Cold build time matters in CI rather than locally, and it is worth checking against the 15-second cold-build target the section overview at the build pipeline and module resolution fundamentals sets. A production map costs roughly 13 seconds of that budget; caching absorbs it in most pipelines, but on a cold runner it is real.

Rebuild Latency by Setting Eval-based settings rebuild in a few hundred milliseconds; full source-map settings take tens of seconds, far past the threshold where the feedback loop stops feeling immediate. Incremental rebuild time (log scale) eval-cheap-module 260 ms eval-source-map 890 ms source-map 19.4 s ≈ 400 ms — past here the edit-and-see loop breaks 100 ms 1 s 20 s Measured on ~1,200 modules; absolute values vary, the ordering does not

Step-by-step verification

  1. Measure, do not infer. Time a cold build and three incremental rebuilds with each candidate. The names suggest an ordering; only measurement gives you the magnitude for your codebase.

  2. Set a breakpoint in original source. With the development setting active, confirm the debugger pauses on the correct line of the pre-transform file, not the loader’s output.

  3. Check variable inspection. If names appear mangled while paused, the module component is missing and you are mapping to post-Babel code.

  4. Check a production trace end to end. Deliberately throw in production and confirm the monitoring service resolves the frame to the right file, line, and column.

  5. Confirm no eval reaches production. Search the production bundle for eval(. Any hit means a development setting leaked into the production configuration.

  6. Re-check CI build time. Confirm the production map generation still fits the pipeline’s budget, and enable build caching if it does not.

Edge cases and gotchas

Content Security Policy blocks eval. A development environment with a strict CSP cannot run eval-based settings at all. Use cheap-module-source-map there — slower rebuilds, but it works.

Test environments. Test runners have their own transform pipeline and their own mapping configuration; a stack trace in a failing test is unaffected by the bundler’s setting.

Loaders that discard maps. A loader that does not forward the incoming map breaks the chain, and everything downstream maps to that loader’s output regardless of the devtool value — the failure diagnosed in fixing source map mismatches in Webpack 5.

Maps for vendor code. Excluding node_modules from mapping speeds up builds noticeably and makes a stack frame inside a dependency unreadable. Worth it while working on application code; costly during a dependency investigation.

FAQ

What does the “cheap” in cheap-source-map actually give up?

Column accuracy. A cheap map records line mappings only, so the debugger can put you on the right line but not the right expression within it. For most development work that is invisible, because breakpoints are line-based. It matters when several statements share a line, in minified output, or when a stack frame’s column is the only way to distinguish two calls — which is why production maps should not be cheap.

Why is eval-based mapping so much faster on rebuilds?

Because it keeps each module’s mapping inside that module rather than in one file describing the whole bundle. When a single module changes, only its own wrapper is regenerated; nothing has to recompute a bundle-wide mapping table. That is exactly the property incremental rebuilds need, and it is why eval-based settings dominate the fast end of the range.

Can I use the same setting for development and production?

You can, and you will be worse off at one end or the other. Development wants the fastest rebuild that still lands on the right line; production wants full fidelity, no eval, and no published map. Those requirements do not overlap, so a single value is always a compromise. Reading the setting from the mode is a one-line change that removes the compromise entirely.