The source-map-loader gotcha that broke our prod debugging
A subtle webpack misconfiguration that silently shipped wrong source maps for two weeks — and the assertion we now run in CI.
It started with a simple alert: an undefined is not a function error in our checkout flow. We clicked the stack trace, expecting the exact line in our React component. Instead, we were staring at a comment block inside a third-party library three files away. For two weeks, every "fix" we shipped was based on hallucinated stack traces, because our source maps were offset by exactly 42 lines—and we had no idea. We weren't debugging the bug; we were debugging the map.
This is the source-map-loader gotcha. It's subtle, it survives code review, it passes local development clean, and it quietly poisons your production debugging until you stop trusting your own tools. Here's how it happens, why we couldn't catch it, and the CI assertion we now run on every build so it never happens again.
The "Ghost" in the Stack Trace
The maddening thing about an offset map is that it doesn't look broken. It points at real code—just the wrong real code. The trace said line 152; the bug was on line 110. Both lines existed, both contained plausible-looking JavaScript, and the offset was consistent, so it never registered as "random garbage" the way a totally missing map would.
Why "line 152" was actually "line 110"
When a map is offset by a fixed number of lines, the resolved location is internally consistent but globally wrong. You read line 152, you study the code there, you form a theory, you ship a fix. The error keeps firing because the real defect is 42 lines up, untouched. The map gave you a confident, precise, completely incorrect answer.
The frustration of the "fixed" bug that keeps reappearing
Nothing erodes trust faster than a bug you've "fixed" three times reappearing in the next release. We started suspecting caching, then a race condition, then a flaky dependency—anything except the map itself, because the map is the one tool you assume is telling the truth. If you've never questioned how a minified coordinate becomes a source line, our deminification walkthrough shows exactly where an offset can creep in.
How source-map-loader Is Supposed to Work
It helps to be precise about what this loader does, because it's often confused with webpack's devtool setting. They are different jobs. devtool tells webpack how to generate maps for your code. source-map-loader tells webpack how to consume existing maps that ship with pre-built files—typically inside node_modules.
Extracting maps from pre-processed files
Many published packages ship transpiled JavaScript alongside a .map that points back to the library's original TypeScript. source-map-loader reads those existing maps so that, in the final bundle, a frame inside a dependency can still resolve to the library author's source rather than its dist output.
The "map of maps"
In a real build, source maps chain. TypeScript emits a map. Babel transforms that and emits another. CSS-in-JS injects its own. Webpack's job is to compose this chain into one final map that goes all the way from the minified bundle back to the original author's source. source-map-loader is one link in that chain, and links have to be applied in the right order.
// webpack.config.js — the "looks correct" version that bit us
module.exports = {
module: {
rules: [
{
test: /\.js$/,
use: ['source-map-loader'],
// no enforce, no thought given to ordering
},
],
},
};The Gotcha: The Double-Mapping Trap
The bug surfaces when source-map-loader processes a file after another loader has already transformed it. The loader finds a sourceMappingURL that referred to the file's original shape, but the file has since been rewritten, so the offsets no longer line up. The map and the code have drifted apart, and the loader faithfully applies the stale map anyway.
The enforce: 'pre' requirement
This is the heart of it. source-map-loader must run before any transforming loader, which in webpack means enforce: 'pre'. Without it, loader ordering is not guaranteed relative to your other rules, and you get a race: sometimes the map is consumed against the original file (correct), sometimes against the transformed file (offset). Omitting enforce: 'pre' is what created our fixed 42-line shift.
Vendor packages with broken or missing maps
Some dependencies ship a sourceMappingURL comment but no actual .map file, or a map with wrong relative paths. source-map-loader hits these and either warns into the void or silently degrades. You need to decide explicitly how to handle a dependency whose own maps are broken, rather than letting the loader guess.
The exclude vs. include mistake
The instinct after the first failure is to slap exclude: /node_modules/ on the loader. That's usually wrong—consuming dependency maps is the entire reason the loader exists. Excluding node_modules throws away the good maps to avoid a few bad ones. The right move is to fix ordering and filter the specific broken packages, not to disable the loader wholesale.
// The corrected rule
module.exports = {
module: {
rules: [
{
test: /\.(js|mjs)$/,
enforce: 'pre', // run BEFORE transforming loaders
use: ['source-map-loader'],
// optionally skip known-broken vendor maps:
exclude: /node_modules\/some-broken-pkg/,
},
],
},
};Why Local Development Didn't Catch It
The cruelest part is that none of this reproduces locally. Dev and prod use different devtool values, and the cheap dev maps mask the very offset that the expensive prod maps expose.
How cheap-module-source-map hides the offset
In development you typically run eval-source-map or cheap-module-source-map. These are fast and column-insensitive—"cheap" maps deliberately drop column information and round to lines. That coarseness papers over small offsets, and the unbundled, unminified dev output is forgiving enough that a line-level shift in a dependency rarely lands you somewhere obviously wrong.
Why production minification is the catalyst
Production uses devtool: 'source-map' with full Terser/esbuild minification. Now everything is collapsed onto one line, column precision matters intensely, and a stale map in the chain compounds rather than gets absorbed. Minification doesn't cause the offset—it reveals the latent error the cheap dev map was hiding. The same code, same loader config, only goes wrong in the build you can't easily inspect.
The Source Map Assertion Script
After two weeks of phantom debugging, we decided we would never again trust a map—we would verify it. The fix is a tiny CI script using the source-map npm package: pick a known symbol, look up where the production map says it lives, and fail the build if the answer is wrong.
The logic
We know App.init lives in App.tsx. So after the build, we find the minified position of that identifier, ask the map to resolve it, and assert that the resolved source is App.tsx and not vendor.js or some library three files over. If the file is wrong or the line is implausibly far off, the build fails before anything reaches production.
// scripts/assert-sourcemap.mjs
import { SourceMapConsumer } from 'source-map';
import { readFileSync } from 'node:fs';
const map = JSON.parse(readFileSync('./dist/assets/index.js.map', 'utf8'));
const js = readFileSync('./dist/assets/index.js', 'utf8');
// Find the minified position of a known, stable symbol.
const needle = 'AuthService';
const idx = js.indexOf(needle);
const line = js.slice(0, idx).split('\n').length;
const column = idx - js.lastIndexOf('\n', idx) - 1;
const consumer = await new SourceMapConsumer(map);
const pos = consumer.originalPositionFor({ line, column });
consumer.destroy();
if (!pos.source || !pos.source.includes('AuthService')) {
console.error('Source map offset detected! Resolved to:', pos.source);
process.exit(1); // fail the build
}
console.log('Source map verified ->', pos.source, pos.line);For a quick manual check on a single artifact, our source map validator runs the same kind of assertion in your browser, and the deminifier lets you confirm a specific trace resolves where you expect.
Integrating with Your Error Tracker
The hidden upload
Once your maps are verified, ship them to your tracker at build time and delete them from your public output—the same generate-upload-delete pattern we detail in hiding source maps from end users. A correct map that leaks to your CDN is still a security problem.
Matching the artifact name
The sourceMappingURL reference (or the release name you upload under) must match exactly what your tracker looks up at ingest. A verified-but-misnamed map resolves to nothing, which looks identical to having no map at all.
Best Practices for Production-Ready Maps
Set devtool: 'source-map' for production—not a cheap variant—so column data survives. Yes, source-map-loader with enforce: 'pre' adds some build time on dependency-heavy projects, but the cost is seconds and the payoff is traces you can trust. Scrub sensitive paths from the maps you upload, and run the assertion script in CI on every build so an offset can never silently ship again.
The lesson from our two-week postmortem is simple: a source map that points at the wrong line is more dangerous than no map at all, because it's confidently wrong. Don't trust your maps—verify them. GlitchReplay resolves traces on ingest from maps you upload at build time, validates them on arrival, and flat-rate pricing means you can re-build and re-test the pipeline as many times as it takes to get the offset to zero. The full setup lives in our source maps documentation.
GlitchReplay is Sentry-SDK compatible, includes session replay and security signals, and never charges per event. Free to start, five minutes to first event.