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.
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.
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.
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})`
);
}
}
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.
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 |
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.
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.