Understanding Source Map v3 Specification and Formats
Source map format fragmentation is the silent killer of production stack trace resolution. Webpack, Vite, Rollup, and esbuild each emit subtly different v3-compliant JSON structures — and observability ingest pipelines at Sentry, Datadog, and OpenTelemetry collectors can silently fail on maps that pass a basic JSON parse but violate field-level constraints. This guide covers every mandatory and optional field in the v3 specification, the exact semantics of each, and the three deployment format strategies you must choose between. It assumes familiarity with the broader source map generation and stack trace debugging problem space. For build-tool-specific configuration, see Configuring Webpack for Production Source Maps and Local Symbolication with Mozilla source-map Library for runtime consumers.
After working through this page you will be able to:
- Parse and validate every field in a v3 source map JSON document against the specification
- Identify the exact failure mode each malformed field produces in a symbolication pipeline
- Construct
sourcesContent-embedded maps for offline symbolication without repository access - Choose between inline, external, and hidden deployment formats based on security and payload constraints
- Write a programmatic validation suite that catches map regressions in CI before deployment
Problem Framing & Symptom Identification
The Source Map v3 specification is a JSON schema published by the TC39-adjacent source-map working group. Every modern build tool claims to emit v3-compliant maps, but “v3-compliant” has ambiguities that produce real ingest failures:
Webpack populates sourcesContent by default in development but silently omits it in production when optimization.minimize is enabled without explicit devtool configuration. The emitted sources paths use webpack:/// protocol prefixes that many symbolication services do not strip correctly.
Vite emits sources with absolute filesystem paths in development mode, which means sources[0] is /Users/alice/project/src/foo.ts on Alice’s machine and /home/runner/work/project/src/foo.ts in CI — the same project, two maps that look identical but produce different symbolication results depending on where sourceRoot resolves.
Rollup emits sources as relative paths from the map’s own location, which is correct behavior — but if you move the .map file to a CDN path that differs from the build output path, every sources reference resolves to a 404. This is documented in Fixing Incorrect Source Map Paths After CDN Deployment.
esbuild does not populate names at all by default, which means identifier-level symbolication (mapping minified a back to userAccountBalance) is unavailable. This is a deliberate design choice, not a bug, but it breaks Sentry’s “variable value in error context” feature.
Third-party noise compounds all of the above. The v3 specification’s ignoreList field (emitted as x_google_ignoreList by older Chrome-era tooling) holds an array of sources indices that DevTools and error trackers should treat as framework or vendor code. Omitting it means every stack trace surfaces frames from node_modules, burying your own application code beneath library internals. Bundlers like Vite 5+ populate it automatically for dependency chunks, but hand-rolled and programmatically generated maps almost never do, so symbolicated traces stay noisy even when field validation passes cleanly.
Ingest failures are the downstream symptom. Sentry’s source map processor rejects maps with version coerced to the string "3" rather than the integer 3. Datadog’s RUM symbolication silently discards frames where sources array length does not match the number of distinct source indices referenced in mappings. OpenTelemetry collector transforms lose sourcesContent when proxied through certain gRPC serializers that treat large string fields as opaque blobs.
The diagnostic pattern is consistent: minified frames in your error tracker with no original file attribution, despite having run a source map upload step. The root cause is nearly always a field-level spec violation that passed basic JSON parsing. The pipeline below shows the four stages where a map is verified before a frame ever resolves — a failure at any earlier stage produces the “no original file” symptom without an error.
Prerequisites & Environment Setup
You need Node.js 18+ for the source-map library’s WASM backend (versions below 18 fall back to a pure-JS decoder that is roughly 10× slower and has known VLQ overflow bugs on large maps).
# Install the core tools used in this guide
npm install --save-dev source-map ajv source-map-validator
# Verify source-map version — 0.7.4+ is required for async WASM consumer
npx source-map --version 2>/dev/null || node -e "console.log(require('source-map/package.json').version)"
Build tool versions this guide is tested against:
| Tool | Min version | Key flag |
|---|---|---|
| Webpack | 5.75+ | devtool |
| Vite | 4.3+ | build.sourcemap |
| Rollup | 3.20+ | output.sourcemap |
| esbuild | 0.18+ | --sourcemap |
# Confirm ajv supports the JSON Schema draft-07 we use for v3 validation
node -e "const Ajv = require('ajv'); const a = new Ajv(); console.log('ajv ok:', a.constructor.name)"
Step-by-Step Implementation
1. Validate the version field
The version field must be the integer 3, not the string "3". This is the most common single-field failure across third-party tooling that generates source maps programmatically.
const fs = require('fs');
function loadAndCheckVersion(mapPath) {
const raw = fs.readFileSync(mapPath, 'utf8');
const map = JSON.parse(raw);
// typeof check catches the string-"3" bug from code generators
if (typeof map.version !== 'number' || map.version !== 3) {
throw new TypeError(
`Source map version must be integer 3, got ${JSON.stringify(map.version)}`
);
}
return map;
}
If you receive a map from a third-party plugin that emits "version": "3", coerce it programmatically before passing it to a consumer — but also file a bug upstream, because every spec-compliant parser should reject the string form.
2. Construct a compliant sources array
The sources array must contain one string entry per distinct original source file referenced in mappings. Paths are interpreted relative to the map’s own URL unless a sourceRoot field is present, in which case sourceRoot is prepended to each entry.
// Construct sources array with explicit sourceRoot to avoid path drift
const sourceMapObject = {
version: 3,
file: 'app.min.js',
sourceRoot: 'https://cdn.example.com/sources/', // absolute root keeps paths stable after CDN move
sources: [
'src/index.ts', // resolved as: https://cdn.example.com/sources/src/index.ts
'src/utils/auth.ts', // each entry is relative to sourceRoot
],
sourcesContent: null, // will fill in step 3
names: [],
mappings: ''
};
When sourceRoot is absent, sources paths resolve relative to the .map file’s URL. Moving the .map file without updating sources breaks resolution silently — the parser succeeds but symbolication returns no original position.
The file field names the generated file this map corresponds to. It is optional per spec but required by Sentry’s ingest pipeline for multi-bundle upload disambiguation.
3. Embed sourcesContent for offline symbolication
sourcesContent is an array parallel to sources. Each index holds either the full text content of the corresponding source file, or null if that file’s content is unavailable. Embedding content is the only reliable strategy for environments where the symbolication service cannot reach your source repository.
const fs = require('fs');
const path = require('path');
function embedSourcesContent(mapObject, projectRoot) {
mapObject.sourcesContent = mapObject.sources.map((sourcePath) => {
const absolutePath = path.resolve(projectRoot, sourcePath); // resolve against project root
try {
return fs.readFileSync(absolutePath, 'utf8'); // embed the original TypeScript/JSX source
} catch {
return null; // null is spec-compliant for unavailable files; do not omit the index
}
});
return mapObject;
}
A common mistake is setting sourcesContent to [] (empty array) instead of null entries when some files are unavailable. An empty array causes index-out-of-bounds failures in strict parsers that compare sourcesContent.length against sources.length. The practical difference between an unpopulated and a populated sourcesContent is stark once a frame reaches your error tracker:
4. Understand the names array and when it is populated
names is an array of identifier strings (variable names, function names, property names) referenced by the fifth field of each VLQ segment in mappings. Not every build tool populates it.
// names array example — only present when the tool emits identifier mappings
const mapWithNames = {
version: 3,
sources: ['src/api.ts'],
names: ['fetchUserData', 'userId', 'response'], // index 0, 1, 2
mappings: 'AAAA,SAASA,YAAY' // the 'S' in position 5 references names[0]
};
// esbuild skips names entirely — this is valid v3 but disables identifier symbolication
const esbuildMap = {
version: 3,
sources: ['src/api.ts'],
names: [], // empty array, not null — segments simply omit the fifth VLQ field
mappings: 'AAAA,SAAS'
};
When names is empty, segments in mappings have at most four VLQ fields. Parsers must not assume a fifth field is present. Feeding an esbuild map into a parser that assumes five fields per segment produces incorrect original-column values for every frame after the first.
5. Parse and validate the mappings VLQ string
The mappings field is a semicolon-separated string where each semicolon represents a line boundary in the generated file. Within each line, comma-separated groups are segments, and each segment is one to five Base64-VLQ-encoded integers representing coordinate deltas. Full decode mechanics are covered in Decoding VLQ Mappings by Hand.
const Ajv = require('ajv');
const ajv = new Ajv({ allErrors: true });
// Structural validation catches type errors before expensive VLQ parsing
const v3Schema = {
type: 'object',
required: ['version', 'sources', 'mappings'],
additionalProperties: true,
properties: {
version: { type: 'integer', const: 3 }, // integer, not string
sources: { type: 'array', items: { type: 'string' } }, // array of strings
names: { type: 'array', items: { type: 'string' } },
mappings: { type: 'string', pattern: '^[A-Za-z0-9+/=,;]*$' }, // valid base64 + separators
sourcesContent: {
type: 'array',
items: { type: ['string', 'null'] } // null entries are legal
}
}
};
const validate = ajv.compile(v3Schema);
function validateMap(mapObject) {
if (!validate(mapObject)) {
throw new Error('v3 schema violation: ' + JSON.stringify(validate.errors, null, 2));
}
// Cross-check array lengths to catch index drift
if (mapObject.sourcesContent &&
mapObject.sourcesContent.length !== mapObject.sources.length) {
throw new Error(
`sourcesContent length (${mapObject.sourcesContent.length}) ` +
`must equal sources length (${mapObject.sources.length})`
);
}
}
Understanding why a single stray character corrupts an entire line requires knowing the encoding’s mechanics. Each Base64-VLQ digit carries five payload bits plus one continuation bit, and the least-significant bit of the first digit in a group is a sign flag. A single character therefore encodes signed values from -15 to 15, and larger deltas chain additional digits until a digit with a cleared continuation bit terminates the group. This is why a corrupted middle character does not just break one number — it desynchronizes the continuation chain and shifts the field boundary for every remaining segment on that line, which is exactly how post-build string rewrites silently poison symbolication.
6. Choose and implement a format strategy
The three deployment formats differ only in how (or whether) the //# sourceMappingURL comment references the map data:
const fs = require('fs');
const path = require('path');
// --- INLINE: embed map as base64 data URI at the bottom of the bundle ---
function appendInlineMap(bundlePath, mapObject) {
const json = JSON.stringify(mapObject);
const b64 = Buffer.from(json).toString('base64');
const pragma = `\n//# sourceMappingURL=data:application/json;charset=utf-8;base64,${b64}`;
fs.appendFileSync(bundlePath, pragma); // appends in-place; bundle size increases ~33%
}
// --- EXTERNAL: write .map file and append reference comment ---
function writeExternalMap(bundlePath, mapObject) {
const mapPath = bundlePath + '.map';
fs.writeFileSync(mapPath, JSON.stringify(mapObject));
const pragma = `\n//# sourceMappingURL=${path.basename(mapPath)}`; // relative path only
fs.appendFileSync(bundlePath, pragma);
}
// --- HIDDEN: write .map file with NO reference in the bundle ---
function writeHiddenMap(bundlePath, mapObject) {
const mapPath = bundlePath + '.map';
fs.writeFileSync(mapPath, JSON.stringify(mapObject)); // file exists on disk for CI upload
// No pragma appended — browsers cannot discover or fetch this map
}
Use hidden maps in production and upload them to your observability platform during the CI/CD release step. Inline maps are appropriate in development and staging where bundle size is not a concern. Never deploy external maps to a public CDN path without access controls — the .map file exposes your full original source code to anyone who inspects network requests. The trade-offs across the three formats map directly onto where the data lives and whether the browser can discover it:
For migrating legacy eval-based maps, see Converting eval Source Maps to Inline Format.
Production Telemetry Integration
Once maps are generated and deployed, the symbolication pipeline must resolve them correctly. The key integration points are:
Upload timing: Maps must be uploaded to your error tracker before users hit the deployed bundle. Race conditions where the bundle goes live 30–60 seconds before the CI upload step completes produce a window of unsymbolicated errors that cannot be retroactively resolved.
Release tagging: Every map upload must be tagged with the exact release identifier (git rev-parse HEAD, npm_package_version, or BUILD_ID). Sentry’s release parameter and Datadog’s version tag must match what the SDK reports from the running application.
VLQ integrity checks: If you run any post-build transforms (header injection, license comment stripping, CDN URL rewriting), re-validate the mappings string afterward. String manipulation that inadvertently alters a Base64 character shifts every subsequent segment offset, corrupting all frame resolution after the mutation point. See Decoding VLQ Mappings by Hand for a decoder you can run as a post-transform sanity check.
const sourceMap = require('source-map');
async function smokeTestMap(mapPath) {
const raw = require('fs').readFileSync(mapPath, 'utf8');
const consumer = await new sourceMap.SourceMapConsumer(raw);
// Resolve a known coordinate from your bundle to verify round-trip accuracy
const pos = consumer.originalPositionFor({ line: 1, column: 42 }); // adjust to a real coord
console.log('Resolved position:', pos); // expect { source, line, column, name } all non-null
consumer.destroy(); // release WASM memory
}
Verification & Testing
A comprehensive verification suite runs in CI immediately after the build step, before any upload:
const sourceMap = require('source-map');
const fs = require('fs');
async function verifyMapFile(mapPath, expectations) {
const raw = fs.readFileSync(mapPath, 'utf8');
const mapObj = JSON.parse(raw);
// 1. Field-level schema check
validateMap(mapObj); // re-use the AJV validator from Step 5
const consumer = await new sourceMap.SourceMapConsumer(raw);
// 2. Sources list sanity — every source should be non-empty
const emptySources = consumer.sources.filter(s => !s);
if (emptySources.length > 0) throw new Error(`Empty source entries: ${emptySources}`);
// 3. Coordinate round-trip for each known test coordinate
for (const { generatedLine, generatedColumn, expectedSource } of expectations) {
const pos = consumer.originalPositionFor({ line: generatedLine, column: generatedColumn });
if (!pos.source || !pos.source.includes(expectedSource)) {
throw new Error(
`Expected source containing "${expectedSource}" at ` +
`${generatedLine}:${generatedColumn}, got ${JSON.stringify(pos)}`
);
}
}
consumer.destroy();
console.log(`✓ ${mapPath} passed all verification checks`);
}
// Example usage — wire into your build script's post-step
verifyMapFile('./dist/app.min.js.map', [
{ generatedLine: 1, generatedColumn: 0, expectedSource: 'src/index' },
{ generatedLine: 1, generatedColumn: 120, expectedSource: 'src/utils/auth' },
]);
Run source-map-validator as a secondary check — it performs its own VLQ decode and cross-references every segment’s source index against the sources array length:
npx source-map-validator ./dist/app.min.js.map
# Outputs: "Source map is valid" or lists specific segment index violations
Failure Modes & Edge Cases
| Scenario | Root Cause | Fix |
|---|---|---|
version is the string "3" |
Code generator used JSON.stringify on a template that had version as a string |
Add a type assertion in the generator; coerce to integer before serializing |
sourcesContent.length differs from sources.length |
A build plugin added a virtual module to sources without appending a corresponding null to sourcesContent |
Ensure every source index has a parallel sourcesContent entry, even if null |
Symbolication returns null source for all frames |
sourceRoot path changed between build and deploy (common after CDN migration) |
Use absolute sourceRoot URLs or update sources paths to be self-contained |
| MIME type rejected by Sentry ingest | External .map file served as text/plain |
Configure CDN to serve .map files with Content-Type: application/json |
| Identifier names missing from error context | Build tool (esbuild by default) does not emit names array |
Switch to Webpack or Rollup with devtool: 'source-map'; esbuild requires --keep-names |
| Frames off by one column in Firefox | SpiderMonkey reports 0-indexed columns; V8 also 0-indexed but some parsers add 1 | Normalize all column values to 0-indexed before passing to originalPositionFor |
ignoreList indices exceed sources.length |
Bundler emitted a stale index after tree-shaking removed a source file | Regenerate ignoreList; drop any index >= sources.length before upload |
FAQ
Why does Sentry reject my map even though it passes JSON.parse?
Sentry’s processor enforces field-level type constraints, not just parseability. The most frequent causes are version as a string, sources containing null entries (not the same as sourcesContent nulls), and mappings values with characters outside the Base64 VLQ alphabet. Run the AJV validator in Step 5 to identify the exact violation.
When should I include sourcesContent vs rely on source fetching?
Embed sourcesContent whenever the symbolication service cannot reach your source repository — which is every production scenario unless you run a self-hosted Sentry with private VCS access. Without embedded content, Sentry and Datadog fall back to fetching the sources URL, which usually 404s from a private repo or returns a login redirect page.
What is the ignoreList field and do I need it?
ignoreList (formerly the non-standard x_google_ignoreList) is an array of integer indices into sources that marks vendored or generated code the debugger should skip. It is optional — maps without it still symbolicate correctly — but populating it keeps your error tracker’s grouping focused on application frames instead of node_modules. Chrome DevTools honors it for “Just My Code” style stepping, and Sentry uses it to de-prioritize third-party frames when it clusters issues, which measurably reduces noise in high-dependency apps.
Does the order of fields in the JSON object matter?
No. JSON objects are unordered and all compliant parsers treat field order as insignificant. However, put version first as a convention — some quick-rejection parsers read it first and exit early on mismatch without parsing the rest of the document.
Can I share one source map across multiple bundles from the same build?
No. Each generated file requires its own map. The file field names exactly one generated artifact. An index map (sections field) can aggregate multiple maps into a single JSON document for micro-frontend scenarios, but each section still references its own standalone map.