Source maps in depth
Read source maps the way tools do: every field, Base64 VLQ, segments and lookups, index maps, debug IDs, and symbolicating production stack traces.
- 01Read a map's fieldsSay what
sources,names,mappings,sourcesContentandignoreListhold. - 02Decode mappings by handTurn Base64 VLQ letters into numbers, and relative segments into real positions.
- 03Symbolicate a stack traceMap a production stack frame back to your file, line and column, and know where debug IDs help.
What a source map holds
A source map links the code a browser runs back to the code you wrote. The bundlers lesson introduced it. This lesson takes one apart, the way DevTools and error trackers read it. Today the format is a standard of its own, ECMA-426.
Here is a file you wrote, and what a minifier made of it:
function addTax(price) {
return price * 1.2;
}function t(n){return n*1.2}And here is the map between them, with its parts printed:
const map = {
version: 3,
file: "cart.min.js",
sources: ["cart.js"],
sourcesContent: ["function addTax(price) {\n return price * 1.2;\n}\n"],
names: ["addTax", "price"],
mappings: "AAAA,SAASA,EAAOC,GACd,OAAOA",
};
console.log(map.mappings.split(";").length + " generated line");
console.log(map.mappings.split(",").length + " segments");
console.log(map.names.join(", "));Line 4 lists the original files, and line 5 holds their code. Line 6 keeps the names the minifier replaced: t was addTax, and n was price. Line 7 is the part that does the real work.
It prints 1 generated line, 5 segments and addTax, price. Each segment links one place in the generated code to one place in cart.js.
cart.min.js. Its last line points to the map, and the map's sources and mappings lead back to cart.js, the code you wrote.Read each row of the table as: "this field holds this."
| Field | What it holds |
|---|---|
version | Always 3. |
file | The generated file this map is for. |
sources | The original files, as URLs or paths. |
sourcesContent | The original code itself, so tools don't need to fetch it. |
names | Original names, such as function and variable names. |
mappings | Which generated positions came from which original positions. |
sourceRoot | A prefix added to every entry in sources. |
ignoreList | The indexes of sources that debuggers should hide, such as library code. |
Picture a translated book with an extra index at the back: for each line of the translation, the page and line it came from in the original, and the characters' original names. You read the translation, but when you find a typo, the index tells you where to fix the original.
- In real life: The translated book
- In JavaScript: The generated code
- In real life: The original book
- In JavaScript: Your source files
- In real life: "Page 3, line 2 was page 7, line 5"
- In JavaScript: A segment in mappings
- In real life: A list of the original character names
- In JavaScript: The names field
A source map is JSON: the original files, their names, and a mappings string that links generated positions to original ones. Tools read it; the browser still runs the generated code.
Base64 VLQ: numbers as letters
mappings stores numbers as letters, to keep maps small. Each letter is one of 64 characters, so it holds 6 bits. This is Base64 VLQ: variable-length quantity, because a big number can take several letters.
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
function encode(value) {
let vlq = value < 0 ? (-value << 1) | 1 : value << 1;
let out = "";
do {
let digit = vlq & 31;
vlq >>>= 5;
if (vlq > 0) digit |= 32;
out += chars[digit];
} while (vlq > 0);
return out;
}
function decode(text) {
const values = [];
let value = 0;
let shift = 0;
for (const char of text) {
const digit = chars.indexOf(char);
value += (digit & 31) << shift;
if (digit & 32) {
shift += 5;
} else {
values.push(value & 1 ? -(value >>> 1) : value >>> 1);
value = 0;
shift = 0;
}
}
return values;
}
console.log(encode(9), encode(-14), encode(1000));
console.log(decode("w+Bd").join(" "));Line 4 makes room for the sign: the number is doubled, and the lowest bit is set when it is negative. Lines 6 to 11 write it 5 bits at a time, lowest first. Line 9 adds 32, the sixth bit, when more letters follow.
Lines 15 to 31 undo it. Line 21 adds each letter's 5 data bits at the right place. When a letter has no 32 bit, line 25 halves the number and uses the lowest bit as its sign.
Line 33 prints S d w+B: 9 needs one letter, -14 one letter, and 1000 three. Line 34 decodes w+Bd back into 1000 -14.
A replay of the example code, not a view inside a JavaScript engine. The decoder runs for real.
script
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; function encode(value) { let vlq = value < 0 ? (-value << 1) | 1 : value << 1; let out = ""; do { let digit = vlq & 31; vlq >>>= 5; if (vlq > 0) digit |= 32; out += chars[digit]; } while (vlq > 0); return out;} function decode(text) { const values = []; let value = 0; let shift = 0; for (const char of text) { const digit = chars.indexOf(char); value += (digit & 31) << shift; if (digit & 32) { shift += 5; } else { values.push(value & 1 ? -(value >>> 1) : value >>> 1); value = 0; shift = 0; } } return values;} console.log(encode(9), encode(-14), encode(1000));Press Step to watch each letter add its bits to value, and see where a number ends. This replays the example code; it isn't a view inside the JavaScript engine.
1000.Reading mappings: lines, segments and fields
A ; in mappings starts the next generated line, and a , starts the next segment on the same line. Each segment has 1, 4 or 5 numbers:
- the generated column;
- which file in
sources; - the original line;
- the original column;
- which name in
names, if the code was renamed.
Every number is the change from the segment before, not a full position. Changes are usually small, so they take fewer letters. The generated column starts again from 0 on each new line.
// The five segments of "AAAA,SAASA,EAAOC,GACd,OAAOA", already turned into numbers.
const relative = [
[0, 0, 0, 0],
[9, 0, 0, 9, 0],
[2, 0, 0, 7, 1],
[3, 0, 1, -14],
[7, 0, 0, 7, 0],
];
let column = 0;
let line = 0;
let originalColumn = 0;
for (const fields of relative) {
column += fields[0];
line += fields[2];
originalColumn += fields[3];
console.log("column " + column + " -> line " + line + ", column " + originalColumn);
}Lines 3 to 7 are the five segments of our map, decoded. Lines 14 to 16 add each change to a running total, and line 17 prints the real positions, counted from 0.
The third line printed, column 11 -> line 0, column 16, is the n in t(n): it came from price on the first line of cart.js. The fourth segment has a negative change, -14: it steps back from column 16 to column 2, on the next line.
EAAOC is five numbers, each added to the same field of the segment before. So generated column 9 + 2 = 11 comes from original column 9 + 7 = 16 on line 0, and name 0 + 1 is price.To find where a generated position came from, a tool takes that line's segments and picks the last one that starts at or before the column. Everything up to the next segment belongs to it.
Index maps and debug IDs
A bundler that joins files can join their maps too, without decoding them. An index map has sections instead of mappings. Each section has an offset, where its code starts in the generated file, and a whole map.
const indexMap = {
version: 3,
sections: [
{ offset: { line: 0, column: 0 }, map: { version: 3, sources: ["cart.js"], names: [], mappings: "AAAA" } },
{ offset: { line: 40, column: 0 }, map: { version: 3, sources: ["shop.js"], names: [], mappings: "AAAA;AACA" } },
],
};
function findSection(line) {
let found = null;
for (const section of indexMap.sections) if (section.offset.line <= line) found = section;
return found;
}
const section = findSection(41);
console.log(section.map.sources[0], "line " + (41 - section.offset.line) + " of its map");Lines 4 and 5 are two sections: cart.js from line 0 and shop.js from line 40. Lines 9 to 13 pick the last section that starts at or before a line. Line 16 prints shop.js line 1 of its map: line 41 minus the offset 40.
The second problem is finding the right map at all. A stack trace names a URL, but the same URL may hold many builds over time. A debug ID fixes that: the generated file and its map carry the same unique ID.
function t(n){return n*1.2}
//# debugId=85314830-023f-4cf1-a267-535f4e37bb17
//# sourceMappingURL=cart.min.js.map{
"version": 3,
"file": "cart.min.js",
"debugId": "85314830-023f-4cf1-a267-535f4e37bb17",
"sources": ["cart.js"],
"names": ["addTax", "price"],
"mappings": "AAAA,SAASA,EAAOC,GACd,OAAOA"
}Line 2 of the file and line 4 of the map hold the same UUID. An error tracker that stores maps by debug ID can always find the map for the exact build that crashed. The debug ID comment goes above the sourceMappingURL line.
Debug IDs are still a proposal. Read each row of the table as: "this proposal is at this stage, and does this."
| Proposal | Stage | What it adds |
|---|---|---|
| Scopes | Stage 3 | Records original scopes and variable names, so debuggers can show them. |
| Range mappings | Stage 3 | Lets one mapping cover a range of generated code, so positions between mappings stay exact. |
| Debug ID | Stage 2 | Gives a generated file and its map the same unique ID. |
Symbolicating production stack traces
In production, errors arrive with positions in the minified code. Symbolicating turns each one back into your file, line, column and names. The one catch: stack traces count from 1, and maps from 0.
const sources = ["cart.js"];
const names = ["addTax", "price"];
// [generatedColumn, sourceIndex, originalLine, originalColumn, nameIndex], decoded from the map.
const segments = [[0, 0, 0, 0], [9, 0, 0, 9, 0], [11, 0, 0, 16, 1], [14, 0, 1, 2], [21, 0, 1, 9, 1]];
function symbolicate(frame) {
const [, line, column] = frame.match(/:(\d+):(\d+)\)$/);
if (line !== "1") return "no mapping";
const generatedColumn = Number(column) - 1;
let found = null;
for (const segment of segments) if (segment[0] <= generatedColumn) found = segment;
const name = found.length === 5 ? " (" + names[found[4]] + ")" : "";
return sources[found[1]] + ":" + (found[2] + 1) + ":" + (found[3] + 1) + name;
}
console.log(symbolicate("at t (https://shop.example/cart.min.js:1:22)"));
console.log(symbolicate("at t (https://shop.example/cart.min.js:1:15)"));Line 4 holds the decoded segments from the last section. Line 7 reads the line and column from the end of the frame. Line 9 takes 1 off the column, and line 11 finds the segment. Line 13 adds 1 back to the line and column.
Line 16 prints cart.js:2:10 (price): column 22 of the minified file is the n in n*1.2. Line 17 prints cart.js:2:3, the return.
Node.js can do this for its own stack traces. Here is a file that throws, minified, with its map next to it:
node --enable-source-maps cart.min.jsError: price must be a number
at addTax (/app/cart.js:3:11)
at Object.<anonymous> (/app/cart.js:7:1)Error: price must be a number
at t (/app/cart.min.js:1:43)
at Object.<anonymous> (/app/cart.min.js:1:92)Without the map, the error points at column 43 of one long line, in a function called t. With --enable-source-maps, Node.js reads the map and points at cart.js line 3, inside addTax, using the names field. The stack trace API lesson shows how stack traces are built.
Explore a real map
Click any underlined piece of the minified code. The playground decodes its segment and highlights the code it came from.
1:171function addTax(price, rate) { 2 const total = price * (1 + rate); 3 return Math.round(total * 100) / 100; 4} 5console.log(addTax(10, 0.2));
EAAOC11shop.js016priceThis piece's segment is "EAAOC". Decoded and added to the segments before it, it gives the original line and column, and the name. Lines and columns in the map count from 0; this sentence counts from 1, like an editor.
Try o twice: the first is total where it is declared, the second where it is used. Both point to the same name.
Practical advice for real projects:
- Make maps in production builds too, so errors from users can be symbolicated.
- If you don't want to publish your code, upload maps to your error tracker instead of serving them, and leave out the
sourceMappingURLcomment. - Keep each map with the exact build it came from; debug IDs make that automatic.
- Use
ignoreListfor library code, so debuggers step over it.
"cart.js"- The list of files the code came from
"addTax", the name before minifying"price", which becamen"AAAA,SAASA,EAAOC"- Which generated column came from which original line
"function addTax(price) {…"- The original code, for a debugger that can't fetch it
Put each piece of information in the source map field that holds it.
Common mix-ups
Most mix-ups come from forgetting that a map is only read by tools, and how it counts.
- "Source maps change how my code runs." No. The browser runs the generated code; only tools read the map.
- "Stack traces and maps count the same way." No. Stack traces count from 1, maps from 0.
- "Each segment stores a full position." No. Each number is a change from the segment before.
- "Every character has its own mapping." No. A segment covers everything up to the next one.
- "Without
sourcesContent, the map is useless." No. Tools can fetch the files fromsources;sourcesContentsaves that step.
The table below puts three of these side by side. Read each row as: "people often think this, but this is true."
| Topic | What people think | What is true |
|---|---|---|
| Source maps | They change how the code runs. | They are only read by tools. The browser runs the generated code either way. |
| Positions | Stack traces and maps count the same way. | Stack traces count from 1; maps count from 0. |
| mappings | Each segment stores full positions. | Each number is the change from the segment before, so most stay small. |
Practice exercises
Each exercise works on the numbers a map is made of. Guess first, then run it.
What does line 3 print?
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
const digits = ["C", "D"].map((char) => chars.indexOf(char));
console.log(digits.map((digit) => (digit & 1 ? -(digit >> 1) : digit >> 1)).join(" "));const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
const digits = ["C", "D"].map((char) => chars.indexOf(char));
console.log(digits.map((digit) => (digit & 1 ? -(digit >> 1) : digit >> 1)).join(" "));C is 2: even, so +1. D is 3: odd, so -1. So it prints 1 -1.
What does line 15 print?
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
function encode(value) {
let vlq = value < 0 ? (-value << 1) | 1 : value << 1;
let out = "";
do {
let digit = vlq & 31;
vlq >>>= 5;
if (vlq > 0) digit |= 32;
out += chars[digit];
} while (vlq > 0);
return out;
}
console.log(encode(16));const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
function encode(value) {
let vlq = value < 0 ? (-value << 1) | 1 : value << 1;
let out = "";
do {
let digit = vlq & 31;
vlq >>>= 5;
if (vlq > 0) digit |= 32;
out += chars[digit];
} while (vlq > 0);
return out;
}
console.log(encode(16));32 doesn't fit in 5 bits, so it takes two letters: g (0 plus the more bit) and B (1).
What does line 4 print?
const mappings = "AAAA,EAAE;;AACA";
const lines = mappings.split(";");
const segments = lines.flatMap((line) => (line ? line.split(",") : []));
console.log(lines.length, segments.length);const mappings = "AAAA,EAAE;;AACA";
const lines = mappings.split(";");
const segments = lines.flatMap((line) => (line ? line.split(",") : []));
console.log(lines.length, segments.length);Three generated lines, one of them empty, and three segments: two on the first line and one on the last.
This prints line 2, column 3, which is wrong. Fix the last line. What does your program print?
const segments = [[0, 0, 0, 0], [9, 0, 0, 9], [14, 0, 1, 2]];
function original(column) {
let found = null;
for (const segment of segments) if (segment[0] <= column) found = segment;
return "line " + (found[2] + 1) + ", column " + (found[3] + 1);
}
// The stack trace says column 14. Stack traces count columns from 1.
console.log(original(14));const segments = [[0, 0, 0, 0], [9, 0, 0, 9], [14, 0, 1, 2]];
function original(column) {
let found = null;
for (const segment of segments) if (segment[0] <= column) found = segment;
return "line " + (found[2] + 1) + ", column " + (found[3] + 1);
}
// The stack trace says column 14. Stack traces count columns from 1.
console.log(original(14 - 1));Column 14 counts from 1, so it is 13 in the map, which belongs to the segment starting at 9: line 1, column 10.
The loop copies the changes as they are. Make it add them up. What does your program print?
const relative = [[0, 0, 0, 0], [4, 0, 1, 2], [6, 0, 0, -1]];
const absolute = [];
for (const fields of relative) {
absolute.push(fields);
}
console.log(absolute.map((fields) => fields.join(",")).join(" | "));const relative = [[0, 0, 0, 0], [4, 0, 1, 2], [6, 0, 0, -1]];
const absolute = [];
let total = [0, 0, 0, 0];
for (const fields of relative) {
total = total.map((value, index) => value + fields[index]);
absolute.push(total);
}
console.log(absolute.map((fields) => fields.join(",")).join(" | "));Adding the changes gives 0,0,0,0, then 4,0,1,2, then 10,0,1,1: the third segment is at column 10, on original line 1, column 1.
This prints app.js line 125, but app.js's map doesn't have 125 lines. Fix it. What does your program print?
const sections = [
{ offset: { line: 0 }, file: "vendor.js" },
{ offset: { line: 120 }, file: "app.js" },
];
function locate(line) {
let found = null;
for (const section of sections) if (section.offset.line <= line) found = section;
return found.file + " line " + line;
}
console.log(locate(125));const sections = [
{ offset: { line: 0 }, file: "vendor.js" },
{ offset: { line: 120 }, file: "app.js" },
];
function locate(line) {
let found = null;
for (const section of sections) if (section.offset.line <= line) found = section;
return found.file + " line " + (line - found.offset.line);
}
console.log(locate(125));Line 125 is in the app.js section, which starts at line 120, so it is line 5 of app.js's own map.
Check your understanding
For each question, ask whether a number is a change or a total, and whether it counts from 0 or from 1. Each answer explains why it's right or wrong.
Question 1 of 6In
mappings, what separates one generated line from the next?Choose an answer to see the explanation.
Question 2 of 6What number is the letter Y?
Read the code, then predictconst chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; const digit = chars.indexOf("Y"); console.log(digit, digit & 1 ? -(digit >> 1) : digit >> 1);Choose an answer to see the explanation.
Question 3 of 6What do these relative fields add up to?
Read the code, then predictconst deltas = [[0, 0, 0, 0], [9, 0, 0, 9], [2, 0, 0, 7]]; let column = 0; let originalColumn = 0; for (const fields of deltas) { column += fields[0]; originalColumn += fields[3]; } console.log(column, originalColumn);Choose an answer to see the explanation.
Question 4 of 6A stack trace says
cart.min.js:1:22. What do you look up in the map?Choose an answer to see the explanation.
Question 5 of 6What is an index map for?
Choose an answer to see the explanation.
Question 6 of 6What problem do debug IDs solve?
Choose an answer to see the explanation.
Key takeaways
A source map is a compact table from generated positions to original ones.
- A map is JSON:
sources,sourcesContent,names, and themappingsstring. - Base64 VLQ stores each number in letters of 6 bits: 5 data bits and a "more" bit, with the sign in the lowest bit.
;separates generated lines,,separates segments, and every number is a change.- Index maps join maps by offset; debug IDs tie a file to its exact map.
- Stack traces count from 1 and maps from 0: take 1 off, look up, add 1 back.
Remember the one-liner.
A source map is a list of small changes that lead from each piece of generated code back to the line you wrote.
Coming next: How minifiers work. They make the small, renamed code that source maps lead back from.