Mapping Production Errors with DevTools Workspaces
You have a production stack frame from your error tracker — app.9f2c1.js:1:44820 — and DevTools already resolves the source map, so clicking it lands on readable .tsx. But the file it opens is a read-only, synthetic webpack:// copy: you cannot set a durable breakpoint, your edits vanish on reload, and there is no way to trace the bug through your real project. This how-to binds a DevTools Workspace to your on-disk checkout so that frame opens the actual file, edits round-trip to disk, and breakpoints stick across reloads. It sits under Debugging Source Maps in Browser DevTools, part of the wider Source Map Generation & Stack Trace Debugging reference. If the frame still lands on minified output first, work through Using Chrome DevTools to Debug Minified Bundles before you start binding folders.
Symptom / Trigger
You paste a coordinate from the error tracker, DevTools symbolicates it, and the Sources panel opens your original component — but under a grey webpack:// tree rather than your project. The tab title carries a small padlock or “read-only” hint, breakpoints appear hollow instead of solid, and any character you type is reverted the moment the page reloads. In the Sources tree the file’s persistence badge is empty: no green dot, no purple dot, just the ephemeral in-memory copy.
Error: Cannot read properties of undefined (reading 'total')
at checkout (app.9f2c1.js:1:44820)
at onClick (app.9f2c1.js:1:39210)
// DevTools opens: webpack://app/./src/components/Cart.tsx (read-only)
// Set a breakpoint, reload, and it is gone — the map has no writable target.
The give-away is that symbolication itself is working. This is not a “source map not found” problem; the map resolves cleanly. What is missing is a writable binding between the deployed URL DevTools loaded and a real path on your disk, so the editor treats the mapped source as a throwaway artifact rather than a file you own.
This distinction matters because the two failures look almost identical in the Sources tree and are fixed in opposite ways. When symbolication is broken you see minified code or a warning banner, and the fix lives on the build or server side. When only the binding is missing you see perfectly readable original code that simply refuses to accept a durable breakpoint. Chasing the wrong one wastes an incident: developers re-upload maps, tweak devtool settings, and clear caches, when the map was fine all along and the only thing absent was a folder grant.
Root Cause Explanation
A source map answers one question: given a coordinate in the minified bundle, where was that code in the original file? It carries a sources array of path-like strings and, optionally, a sourcesContent array holding the original text. When DevTools has only the map, it reconstructs each original file from sourcesContent and displays it — but that reconstructed buffer has no home on your filesystem, so it is read-only by definition. There is nowhere to write back to.
// What the map alone gives DevTools: a synthetic, read-only source string.
const map = await (await fetch('https://cdn.example.com/assets/app.9f2c1.js.map')).json();
console.log(map.sources[0]); // "webpack://app/./src/components/Cart.tsx"
console.log(typeof map.sourcesContent[0]); // "string" — the code, but not a file handle
// DevTools renders this string; it has no path on disk to persist your edits to.
A Workspace closes that gap. It asks the browser for read/write access to a folder and then matches each map source against files under that folder by path suffix. When webpack://app/./src/components/Cart.tsx lines up with src/components/Cart.tsx in the folder you granted, DevTools stops showing the reconstructed buffer and starts showing the real file — with a live handle it can write through. The reason a naive setup fails is almost always that the suffix does not line up: a monorepo nests the app one directory deeper than the map’s prefix expects, so no file matches and DevTools silently falls back to the read-only copy.
The matching is deliberately loose about the leading segments so that the many synthetic prefixes bundlers invent — webpack://, webpack-internal:///, a configured sourceRoot, or a CI machine’s absolute build path — never break the binding. DevTools trims those prefixes and compares only the tail of each entry against the tree you added, longest match winning. The practical consequence is that you are never matching the whole path, only its final directories and filename, and the single most common mistake is granting a folder that sits at the wrong depth relative to that tail. Getting the depth right the first time is the difference between an instant green dot and ten minutes of guessing why nothing bound.
Step-by-Step Fix
- Confirm the map resolves and note the exact
sourcesprefix you must match on disk.
// Run in the DevTools Console on the failing production tab.
const m = await (await fetch('https://cdn.example.com/assets/app.9f2c1.js.map')).json();
console.log(m.sourceRoot || '(none)'); // any prefix DevTools prepends
m.sources.slice(0, 3).forEach((s) => console.log(s)); // e.g. webpack://app/./src/...
// The tail after the synthetic prefix (src/components/Cart.tsx) is what must exist on disk.
- Check out the exact release the deployed bundle was built from so line numbers align.
# Match your local tree to the live build, not just latest main.
git fetch --all --tags
git checkout "8c1f4ab" # the git SHA the production bundle reports (see step 6)
npm ci # restore the exact dependency tree from the lockfile
- Open Sources, add the folder whose layout matches the suffix, and grant access.
// UI path: Sources > Workspace > "Add folder", then accept the permission prompt.
// Pick the directory whose children reproduce the map's suffix, e.g. the folder
// that CONTAINS src/ so "src/components/Cart.tsx" resolves — in a monorepo that is
// packages/web, NOT the repository root. DevTools marks matched files with a green dot.
- Open the tracked frame and verify it now points at the real, writable file.
// Paste the tracker's coordinate here to jump straight to the mapped position.
const frame = { file: 'app.9f2c1.js', line: 1, column: 44820 };
console.log('Open Sources, Cmd/Ctrl+P this file:', frame.file);
// The opened tab should now be src/components/Cart.tsx with a GREEN persistence dot,
// not a grey webpack:// entry — that dot is proof the workspace binding took.
- Set a breakpoint on your source line and reload to confirm it survives.
// Click the gutter on the mapped line (a SOLID blue marker, not hollow).
// Reload the page: a bound workspace re-attaches the breakpoint by file path,
// so it should still be there and still pause execution on the same source line.
debugger; // or set it in the gutter — either binds through the workspace to disk
- Expose the build’s release at runtime so you can prove the checkout matches the tab.
// src/monitoring.js — make the deployed build id readable from the Console.
const RELEASE = __RELEASE__; // injected at build time (git SHA or semver)
window.__APP_RELEASE__ = RELEASE;
// On the live tab, window.__APP_RELEASE__ must equal the SHA you checked out in step 2,
// otherwise the workspace binds real files whose lines drift from the running bundle.
Verification
Prove the binding is genuine rather than a coincidental visual match. A bound workspace persists edits to disk, so the definitive test is to write a comment through DevTools and read it back from the shell — if it appears, the deployed URL is truly wired to your file.
# 1. In DevTools, open the mapped file, add a line like // workspace-check 8c1f4ab
# and press Cmd/Ctrl+S to save through the workspace.
# 2. Confirm the edit landed on disk, in the real repo, not an override buffer:
grep -n "workspace-check 8c1f4ab" src/components/Cart.tsx \
&& echo "OK: workspace writes to disk" \
|| echo "FAIL: still the read-only webpack:// copy"
# 3. Confirm you are debugging the build the live tab actually runs.
echo "Checked out: $(git rev-parse --short HEAD)" # must equal window.__APP_RELEASE__
The disk read is the part people skip, and it is the only step that distinguishes a real binding from a lucky-looking read-only copy. A file reconstructed purely from sourcesContent will happily accept keystrokes in the editor and even show them until you reload; the save simply goes nowhere. Reading the comment back out of the actual repository file — with the exact release SHA embedded in the marker — proves the write reached disk and that you are editing the tree you think you are.
Then confirm the interactive path end to end: with the breakpoint from step 5 in place, trigger the code path that produced the tracked error and check that execution pauses on your source line with real local variables in scope.
// Console assertion you can run once execution is paused on the mapped line.
// A bound workspace gives you the real scope; column mapping puts you on the exact call.
console.assert(window.__APP_RELEASE__ === '8c1f4ab', 'wrong build loaded for this map');
console.assert(document.querySelector('.cart-total'), 'not on the checkout path');
// Both passing means the tracked frame, the map, and your disk are in agreement.
Edge Cases & Gotchas
- Monorepo depth mismatch. If the map’s suffix is
src/components/Cart.tsxbut your repo nests the app underpackages/web/src/..., addpackages/web— not the repository root — or nothing matches and DevTools silently keeps the read-only buffer. - Stale checkout, right file. A workspace will happily bind a file whose line numbers no longer match the deployed bundle. Always cross-check
window.__APP_RELEASE__againstgit rev-parse HEAD; a green dot only proves the path matched, not the content. - Workspace versus Overrides confusion. A Workspace writes your edits to your own source on disk. Local Overrides intercepts the network response and serves an edited copy you do not build. Reach for a workspace when you own the code and want durable breakpoints.
- Absolute-path maps. Some bundlers emit absolute
sourceslike/Users/ci/build/src/...with nowebpack://prefix. Suffix matching still works, but only the trailingsrc/...segment needs to exist under the folder you add; the CI machine’s absolute prefix is ignored.
FAQ
Why does my breakpoint disappear on reload even though DevTools opens my source?
Because DevTools is showing the read-only, map-reconstructed copy, not a file bound to disk. Without a workspace there is no path handle to re-attach the breakpoint to, so it is discarded when the page reloads. Add the folder under Sources → Workspace, confirm the file shows a green persistence dot, and the breakpoint will re-bind by path on every reload.
How do I know which folder to add for the workspace to match?
Print the map’s sources array in the Console and read the part after any webpack:// or sourceRoot prefix. That trailing path — for example src/components/Cart.tsx — must exist under the folder you add. Add the directory that contains that suffix; in a monorepo that is usually the package root such as packages/web, not the repository root.
Can I bind a workspace to a build I did not compile locally?
Yes, as long as you check out the exact commit the deployed bundle was built from. The workspace matches files by path, so a fresh git checkout <sha> plus npm ci gives DevTools real files whose lines align with the running bundle. Verify with window.__APP_RELEASE__; if it disagrees with your HEAD, the lines will drift even though the paths match.
Related
- Debugging Source Maps in Browser DevTools — the parent guide covering map resolution, Workspaces, and Local Overrides across Chrome and Firefox.
- Source Map Generation & Stack Trace Debugging — the reference spanning generation, upload, and symbolication of source maps.
- Using Chrome DevTools to Debug Minified Bundles — what to do first when the frame still opens minified output instead of source.
- Fixing Incorrect Source Map Paths After CDN Deployment — the emit-side fix when the map’s
sourcesprefixes never line up with any folder you add.