Understanding the sources and sourcesContent Fields

The sources and sourcesContent arrays are the two halves of how a source map answers the question “which original file, and what did it look like?” — and when they disagree, your symbolicated stack trace shows the right file name but no surrounding code. This how-to explains the strict index-parallel relationship between the two arrays, and when you must embed original code inline versus resolving it from disk. It builds on Understanding the Source Map v3 Specification & Formats and fits inside the broader work of Source Map Generation & Stack Trace Debugging. If you have not yet decoded a mappings string, the decoding VLQ mappings by hand walkthrough shows where the sourceIndex that keys into sources actually comes from.

Symptom / Trigger

A frame resolves to a real file path, but the code snippet is empty, and the resolver logs a lookup miss:

TypeError: Cannot read properties of undefined (reading 'total')
    at checkout (app.min.js:1:24187)

# After symbolication:
    at checkout (src/checkout/cart.ts:42:15)   ← file + line resolved
# but the context viewer shows:
sourcesContent[2] === undefined
ENOENT: no such file or directory, open '/build/src/checkout/cart.ts'
# The name is correct; the SOURCE TEXT is missing.

The originalPositionFor call returns the right source and line, yet sourceContentFor throws or returns null. The map knows where the code came from but not what it said. In an error dashboard this shows up as a stack trace whose frames each have a clickable file and line number, but whose expandable code preview is empty or shows a loading spinner that never resolves. Developers assume the map is broken; in fact it is doing exactly what it was built to do — it simply was not built to carry the original text.

How a mapping’s sourceIndex keys into both arraysA decoded source index of 2 selects sources[2] for the file path and sourcesContent[2] for the original text, showing the two arrays are index-parallel.sourceIndex = 2from mappingsources[2] = cart.tsthe file pathsourcesContent[2]the file text

Root Cause Explanation

In the v3 format, sources and sourcesContent are two independent arrays that the spec treats as index-parallel: for any integer i, sources[i] is the path of the original file and sourcesContent[i] is that file’s full text. There is no key, no filename match, no path resolution — the only thing that binds a path to its content is the shared array position. When a decoded mapping yields sourceIndex = 2, the resolver reads sources[2] for the label and sourcesContent[2] for the snippet.

sourcesContent is optional. A perfectly valid map can omit it entirely, or provide an array with null in some slots and strings in others. When a slot is null or the whole array is absent, the consumer is expected to fetch the original text itself using the sources path (resolved against sourceRoot). That fetch is exactly what fails in a production symbolication server that has the map but not the original repository checked out on disk.

// The broken assumption: that a resolved source path means the text is available.
const pos = consumer.originalPositionFor({ line: 1, column: 24187 });
// pos.source === 'src/checkout/cart.ts'  — resolved fine
const code = consumer.sourceContentFor(pos.source); // returns null!
renderSnippet(code.split('\n')[pos.line - 1]); // throws: code is null

The map author (your build tool) decides whether content ships inside the map. If the tool was configured to exclude sourcesContent, the map is smaller but only symbolicates fully on a machine that can read the original files. The mismatch — right path, missing text — is not a bug in your resolver; it is a map that was never told to carry its own source text.

It helps to picture the two arrays as columns in the same table, joined on row number rather than on any value. The sources column holds strings like webpack://app/src/checkout/cart.ts — display paths, often prefixed with a scheme that never corresponds to a real filesystem location. The sourcesContent column holds the entire original file as a single string with embedded newlines. Because the join is positional, reordering one array without reordering the other silently corrupts every mapping that references a shifted index: the frame will now show file A’s path beside file B’s code. This is why post-processing tools that “clean up” or sort sources are dangerous unless they renumber the mappings too. The safest mental model is that neither array is ever meaningful on its own — a sourceIndex is a coordinate into a pair of columns, and the two columns must always move together.

Step-by-Step Fix

1. Confirm the two arrays are the same length and index-aligned

// A well-formed map keeps sources and sourcesContent the same length.
function assertParallel(map) {
  if (!map.sourcesContent) return false; // content simply not embedded
  if (map.sourcesContent.length !== map.sources.length) {
    throw new Error(`length mismatch: ${map.sources.length} vs ${map.sourcesContent.length}`);
  }
  return true; // safe to index sourcesContent[i] alongside sources[i]
}

2. Look content up by index, never by filename match

// sourceIndex comes from the decoded mapping segment, not from a path string.
function contentForIndex(map, sourceIndex) {
  const text = map.sourcesContent?.[sourceIndex]; // may be a string, null, or undefined
  return typeof text === 'string' ? text : null;  // normalise absent slots to null
}

3. Fall back to a disk or network read only when the slot is null

// Embedded content wins; otherwise resolve the path against sourceRoot and fetch.
async function resolveSource(map, sourceIndex, readFile) {
  const embedded = contentForIndex(map, sourceIndex);
  if (embedded !== null) return embedded; // no I/O needed — inline text present
  const path = (map.sourceRoot || '') + map.sources[sourceIndex]; // join sourceRoot
  return readFile(path); // last resort: read the original file yourself
}

4. Re-run the build with content embedding turned on

# Webpack: choose a devtool variant that inlines sourcesContent
# 'source-map' embeds sourcesContent by default; verify it is not stripped.
npx webpack --mode production --devtool source-map

# esbuild: sourcesContent is on by default — keep it enabled explicitly
npx esbuild app.ts --bundle --sourcemap --sources-content=true --outfile=app.min.js

From build flag to self-contained mapThree stacked stages: enabling sources-content during the build, the map embedding original text, and a symbolication server resolving frames with no repository on disk.sources-content = truemap carries its own textoffline symbolication

5. Repair an existing map by injecting content from source

// Backfill sourcesContent for a shipped map when you still have the originals.
const fs = require('fs');
function embedContent(map, readFile) {
  map.sourcesContent = map.sources.map((rel) => {
    try { return readFile((map.sourceRoot || '') + rel); } // read each path in order
    catch { return null; } // preserve the slot so indexes stay aligned
  });
  return map; // now self-contained; index i still lines up with sources[i]
}
const map = JSON.parse(fs.readFileSync('app.min.js.map', 'utf8'));
fs.writeFileSync('app.min.js.map', JSON.stringify(embedContent(map, (p) => fs.readFileSync(p, 'utf8'))));

Backfilling content works only while you still hold the exact originals that produced the bundle. If a dependency was bumped or a file was edited after the release shipped, the text you inject will not match the line and column coordinates baked into the mappings, and your snippets will drift by a few lines. For anything you deploy, the reliable pattern is to embed sourcesContent at build time and archive the map alongside the release artifact, so the map and the exact source it describes travel together forever. Treat a map without embedded content the way you would treat a debug symbol file with no matching binary: technically parseable, but only useful next to the precise inputs that created it.

Verification

const { SourceMapConsumer } = require('source-map');
const fs = require('fs');

async function verify() {
  const raw = fs.readFileSync('app.min.js.map', 'utf8');
  const map = JSON.parse(raw);

  // 1. Arrays are index-parallel.
  console.assert(map.sourcesContent.length === map.sources.length, 'array lengths differ');

  // 2. Every referenced source has non-null embedded text.
  const consumer = await new SourceMapConsumer(raw);
  for (const src of map.sources) {
    const text = consumer.sourceContentFor(src, /* returnNullOnMissing */ true);
    console.assert(text && text.length > 0, `missing content for ${src}`);
  }
  consumer.destroy();
  console.log('All sources carry embedded content — offline symbolication is safe');
}
verify();
// Expected: "All sources carry embedded content — offline symbolication is safe"

Edge Cases & Gotchas

  • A null slot is legal, not a bug. The spec explicitly allows sourcesContent[i] to be null for a source whose text was intentionally not embedded. Your resolver must treat null as “fetch it yourself”, not “the map is corrupt”.
  • sourceRoot prefixes sources, not sourcesContent. The sourceRoot string is prepended to each sources path when resolving a URL, but sourcesContent is looked up purely by index and never touched by sourceRoot. Applying the prefix to the content lookup is a common mistake.
  • Embedding original code leaks it. Inlining sourcesContent puts your unminified TypeScript inside a .map file. If that map is publicly reachable next to the bundle, anyone can read your source. Ship maps to your error tracker privately rather than to a public path — see converting eval source maps to inline format for the inline-content tradeoffs.
  • Duplicate paths in sources still need distinct indexes. Two entries can share a path (e.g. a virtual module included twice), and each keeps its own sourcesContent slot. Never deduplicate sources after the fact — it renumbers every downstream sourceIndex.
  • Scheme-prefixed paths are display labels, not fetch URLs. Webpack writes paths like webpack://project/./src/app.ts; esbuild and Vite use their own prefixes. When sourcesContent is present you never need to resolve these, which is another reason embedding is the more robust choice — you avoid guessing how each tool’s virtual path maps back to a real file. Strip or rewrite the prefix only for display, never as the key into either array.
  • Windows and POSIX path separators can diverge. A map generated on one platform may list src\checkout\cart.ts while your resolver looks for src/checkout/cart.ts. Because sourcesContent is keyed by index rather than by the path string, embedding sidesteps this class of separator mismatch entirely.

Embedded content versus resolve-from-diskLeft panel lists the drawbacks of omitting sourcesContent; right panel lists the benefits of embedding original text directly in the map.No sourcesContentNeeds repo on diskBlank code snippetsSmaller map fileEmbedded contentSelf-contained mapFull code contextLarger payload

FAQ

Why does my frame resolve to a file name but show no code?

The mapping decoded a valid sourceIndex, so sources[i] gave a path, but sourcesContent[i] was null or absent and the resolver had no original file to read. Rebuild with content embedding enabled, or point the resolver at a directory that contains the original sources at the paths listed in sources.

Is sourcesContent required by the v3 spec?

No. It is optional, and individual slots may be null. When it is missing, a consumer is expected to fetch the text using the sources path resolved against sourceRoot. Embedding it simply makes the map self-contained so no fetch is needed.

Can sources and sourcesContent ever have different lengths?

A conformant map keeps them the same length so index i lines up in both. Some tools emit a shorter sourcesContent and rely on trailing slots being treated as absent, but you should normalise to equal length and align by index before indexing either array.