Debugging Production Builds with Source Maps

Debug a minified, bundled MV3 extension as if it were source: generating source maps, why sourceMappingURL fails in packaged extensions, loading maps locally in DevTools, symbolicating user stack traces, and keeping maps out of the store package.

Published October 2, 2026 Updated October 2, 2026 7 min read
Table of Contents

A user sends a screenshot: TypeError: Cannot read properties of undefined (reading 'id') at e.r (sw.js:1:48213). Line 1, column 48,213 of a minified service worker is not a debugging aid. The development build doesn’t reproduce the bug, and the production build in DevTools is an unreadable single line. Source maps bridge the gap — they map positions in the bundled output back to your original files and lines. But extensions add twists: maps should not ship to users, DevTools must still find them, and stack traces from users need translating offline. This guide sets up source maps for production debugging without putting them in the package. It belongs to debugging extension contexts.

How source maps reach DevTools

A bundler can emit bundle.js.map next to bundle.js and add a //# sourceMappingURL=bundle.js.map comment at the end of the bundle. When DevTools loads the script, it fetches the map from that URL — relative to the script’s own URL, so chrome-extension://<id>/bundle.js.map — and shows original sources in the Sources panel, with breakpoints and stack traces mapped. If the map is inside the package, this just works, but it ships your full source (embedded via sourcesContent) to every user and enlarges the package. “Hidden” source maps are generated without the comment; DevTools then has no URL to fetch, but you can attach the map manually, and error-reporting services can use it server-side.

Three ways to use production source mapsMaps shipped in the package are found automatically but expose source; hidden maps kept as build artifacts can be attached manually in DevTools or uploaded to an error service that symbolicates user stack traces.Buildsourcemap: 'hidden'*.map artifactsstored per releasePackageno .map filesused byDevToolsAdd source map…Error servicesymbolicated tracesCLI symbolicateuser screenshots
Build maps for every release; ship them to DevTools and your error service, not to users.

Step-by-step: production debugging with maps

1. Generate hidden source maps for every release

1// vite.config.ts
2export default defineConfig({
3  build: { sourcemap: "hidden", minify: "esbuild" },
4});
1// esbuild alternative
2await esbuild.build({ entryPoints: ["src/sw.ts"], bundle: true, minify: true, sourcemap: "external", outdir: "dist" });

Execution context: the build. hidden (Vite/Rollup) and external (esbuild) both write .map files without adding a sourceMappingURL comment to the bundle. Keep sourcesContent enabled (the default) so the map contains your original source — needed when debugging without a matching checkout.

2. Keep maps out of the package, but keep them

1mkdir -p release/maps/$VERSION
2find dist -name '*.map' -exec mv {} release/maps/$VERSION/ \;
3node scripts/zip.mjs                 # packages dist/ — now map-free

Execution context: the release job. Move maps out before zipping and store them as release artifacts (a private bucket, the CI artifact store, or your error-reporting service). Each map must be tied to the exact version that shipped — a map from a different build gives confidently wrong answers. See building reproducible release ZIPs.

Source map strategies comparedNo maps, maps shipped in the package, hidden maps attached locally, and hidden maps uploaded to an error service compared on DevTools experience, source exposure, package size and user stack traces.StrategyDevToolsSource exposedUser tracesNo mapsMinified onlyNoUnreadableMaps in packageAutomaticYes, to everyoneReadable locallyHidden, attach locallyManual attachNoVia CLIHidden + error serviceManual attachNoAutomatic
Hidden maps plus upload gives readable traces without shipping source.

3. Attach a map manually in DevTools

Open DevTools for the context (for the service worker, from chrome://extensions), go to Sources, open the minified file, right-click inside the editor and choose Add source map…, then enter a URL for the map. DevTools can load it from a local file server — run npx http-server release/maps/2.4.0 -p 8765 --cors and enter http://localhost:8765/sw.js.map. The original sources appear in the file tree; set breakpoints there and they map onto the minified code. The attachment lasts for the DevTools session.

4. Load the production build unpacked to reproduce

1unzip release/extension-2.4.0.zip -d /tmp/readable-2.4.0
2# chrome://extensions → Load unpacked → /tmp/readable-2.4.0

Execution context: a developer machine. Loading the exact released package (rather than a fresh dev build) guarantees the code matches the map. For short debugging sessions you can instead copy the maps next to the scripts in the unpacked folder and append //# sourceMappingURL=sw.js.map — never commit or ship that.

From a user's screenshot to the line of sourceA user reports an error at sw.js line 1 column 48213 in version 2.4.0; the developer fetches the 2.4.0 maps, runs a symbolicate script, and gets src/sync/merge.ts line 87, where item.remote is undefined for items created offline.User reportDeveloperMaps v2.4.0sw.js:1:48213 (v2.4.0)symbolicate(sw.js.map, 1, 48213)src/sync/merge.ts:87:22item.remote undefined for offline items
Version-matched maps turn columns into lines of real code.

5. Symbolicate stack traces offline

 1// scripts/symbolicate.mjs
 2import { readFile } from "node:fs/promises";
 3import { SourceMapConsumer } from "source-map";
 4
 5const [, , mapPath, ...frames] = process.argv;                     // frames like "1:48213"
 6const map = JSON.parse(await readFile(mapPath, "utf8"));
 7await SourceMapConsumer.with(map, null, (c) => {
 8  for (const f of frames) {
 9    const [line, column] = f.split(":").map(Number);
10    const pos = c.originalPositionFor({ line, column: column - 1 });   // map columns are 0-based
11    console.log(`${f} → ${pos.source}:${pos.line}:${pos.column + 1} (${pos.name ?? "?"})`);
12  }
13});
14// node scripts/symbolicate.mjs release/maps/2.4.0/sw.js.map 1:48213 1:20117

Execution context: Node on a developer machine. Browser stack traces use 1-based columns; the source-map library expects 0-based, hence the adjustment. Feed it each frame from the user’s trace to rebuild the original stack.

6. Upload maps to your error service

Error-reporting services such as Sentry accept source map uploads per release and symbolicate incoming errors automatically. Extension stack frames use chrome-extension://<id>/ URLs whose ID differs between unpacked, store and other browsers’ builds; normalise frame URLs to a stable prefix (for example app:///) before sending, and upload maps with the same prefix. See uploading source maps for readable stack traces.

7. Make minified names less painful

Keep function names in production where it is cheap: keepNames: true in esbuild preserves function.name and class names, so stack traces show mergeItems instead of e.r even before symbolication. The size cost is small for most extensions.

Common mistakes

  • Shipping maps in the store package. Exposes source and grows the package.
  • Maps from a different build. Mappings are confidently wrong.
  • Off-by-one columns. Browser traces are 1-based; source-map APIs are 0-based.
  • Not storing maps per version. You cannot debug last month’s release.
  • Varying extension IDs in frame URLs. Normalise before symbolicating.

Cross-browser variation

  • Chrome / Edge: “Add source map…” in the Sources panel; DevTools fetches maps for extension scripts relative to chrome-extension:// URLs.
  • Firefox: the debugger supports source maps for extension code; AMO reviewers may also want source maps or source code for minified files.
  • Safari: Web Inspector supports source maps when the map URL is reachable; attaching manually is more limited — loading the unpacked build with maps alongside is the reliable route.

Verification

  1. Confirm the release ZIP contains no .map files.
  2. Attach the stored map in DevTools and set a breakpoint in an original source file; confirm it hits.
  3. Symbolicate a known frame and confirm it points to the right line.
  4. Confirm the error service shows original file names for a test error.

FAQ

Do source maps affect runtime performance?

No. Browsers only load them when DevTools is open.

Can I use inline source maps?

They embed the map in the bundle — the worst option for production, as they ship source and bloat every file.

Should I obfuscate instead?

Store policies restrict obfuscation; minification with private maps is the accepted approach.

What if I lost the maps for an old release?

Rebuild the exact tagged commit with the same toolchain. With a reproducible build, the regenerated maps match the shipped code; without one, they may not.

Do maps work for content scripts too?

Yes. Content scripts run from chrome-extension:// URLs, so attach the map to the script in the page’s DevTools under the extension’s content-script source tree, exactly as for the service worker.

Should maps include the original source?

For private storage, yes — sourcesContent lets you debug without a matching checkout. Strip it only if maps are stored somewhere less trusted than your repository.

Can I keep maps private but still let a colleague debug?

Share them through the same private artifact store with access controls, never by attaching them to public releases.

Other Testing, Debugging & Performance Optimization Resources