Source maps are the main piece in the jigsaw puzzle of mapping symbols and locations from "built" JavaScript files back to the original source code. When you debug minified JavaScript in your browser's DevTools and see the original source with proper variable names and formatting, you're witnessing source maps in action.
For example, when your browser encounters an error at bundle.min.js:1:27698, the source map translates this to src/index.ts:73:16, revealing exactly where the issue occurred in your original TypeScript code:
But how does this actually work under the hood? In this post, we'll take a deep dive into the internals of source maps, exploring their format, encoding mechanisms, and how devtools use them to bridge the gap between production code and developer-friendly sources.
The TypeScript Build Pipeline
Modern JavaScript builds typically involve three main stages:
- Transpilation: TypeScript → JavaScript
- Bundling: Combining modules into a single file
- Minification: Compressing code for production
At each stage, source maps preserve the connection back to the original code.
Stage 0: Source TS files
The original TypeScript source files with full type annotations.
Source Files
1export function add(a: number, b: number): number {2 return a + b;3}
1import { add } from './add';23export function computeFibonacci(n: number): number {4 if (n <= 1) return n;5 return add(computeFibonacci(n - 1), computeFibonacci(n - 2));6}
1import { computeFibonacci } from './fibonacci';23const result = computeFibonacci(10);4console.log(`Fibonacci(10) = ${result}`);
No source map at this stage
The Source Map File Format
Source maps use JSON format, typically with a .js.map extension. Let's examine a source map structure from our add.js.map file:
{
"version": 3,
"file": "add.js",
"sourceRoot": "",
"sources": ["add.ts"],
"names": ["add", "a", "b"],
"mappings": "AAAA,OAAO,SAAS,IAAI,CAAC,EAAE;EACrB,OAAO,IAAI;AACb"
}
Fields Breakdown:
version: Indicates the source map version (currently always3).file: The generated file name this source map corresponds to.sourceRoot: Optional prefix for all source URLs. Useful when sources are hosted elsewhere.sources: Array of original source file paths from which the generated file was built.sourcesContent: Optional array containing the actual source code. This allows DevTools to display sources even if the original files aren't accessible. Usually disabled in production builds.names: Array of original identifiers (variable names, function names, etc.) that appear in the source. Referenced by the mappings.mappings: The compressed mapping data. This is the heart of the source map and uses VLQ encoding. More on this below.
Understanding the Mappings: VLQ Encoding
The mappings field is where the real magic happens. It contains the actual position mappings between every token in the generated JavaScript file and its corresponding location in the original source files.
Essentially, it answers the question: "For this character at line X, column Y in the minified file, where was it originally located?"
This mapping data tracks:
- The file path and name of the original source file
- The exact line and column in the source file
- The original variable/function name (if renamed during minification)
But instead of storing this as a massive JSON array of positions, which would be larger than the minified code itself, source maps use a highly compressed format. Here's what the encoded string looks like:
"AAAA,OAAO,SAAS,IAAI,CAAC,EAAE;EACrB,OAAO,IAAI;AACb"
To keep file sizes manageable, mappings use Variable Length Quantity (VLQ) encoding with Base64 characters. Let's break this down.
The Mapping Structure
The mappings string is a series of segments separated by commas and semicolons:
"segment,segment,segment;segment,segment;segment"
We'll see significance of commas and semicolons shortly, but first, what is a "segment"?
Each segment represents a mapping from a position in the generated file to a position in the source file. Segments come in three flavors:
-
1 value: This referenced column doesn't map to any source (e.g., webpack-generated code)
[generatedColumn] -
4 values: This is the most common case, mapping a position in the generated file to a position in the source file:
[generatedColumn, sourceFileIndex, sourceLine, sourceColumn] -
5 values: Same as 4, plus a reference to the original name of the variable/function:
[generatedColumn, sourceFileIndex, sourceLine, sourceColumn, nameIndex]
The most common case is 4 values (basic position mapping). The 5th value is only added when a variable or function was renamed during minification.
But wait, notice that segments only contain the column in the generated file, not the line number. How does the decoder know which line a segment belongs to?
The answer lies in the structure: semicolons act as line breaks. The position of segments between semicolons determines their line number in the generated file.
This is why empty lines in the generated file still need semicolons, they maintain the line count even with no mappings.
Let's see how this works with a real example:
Notice how the decoded values give relative positions, each value represents the difference from the previous position, not absolute coordinates. This is crucial: instead of encoding large column numbers like 27698 in minified files, source maps only store small deltas like +7 or +15, making the encoded strings much more compact.
Now that we understand the mapping structure, let's see how these numbers actually get transformed into the Base64 alphabet characters we see in the mappings string.
How VLQ Encoding Works
VLQ (Variable Length Quantity) encoding is an efficient way to represent numbers using as few bytes as possible. It's perfect for source maps because most position differences are small numbers.
The encoding process has three main steps:
1. Encode the sign bit
Since we need to handle both positive and negative differences (code can move backward), VLQ uses the least significant bit (LSB) to encode the sign:
Positive number: LSB = 0
Negative number: LSB = 1
Examples:
5 → binary: 101 → with sign bit: 1010 (LSB=0 for positive)
-5 → binary: 101 → with sign bit: 1011 (LSB=1 for negative)
2. Split into 5-bit groups
Each Base64 character can represent 6 bits, but we need 1 bit as a "continuation" flag to indicate if more characters follow. This leaves 5 bits for data:
[continuation bit][5 data bits]
↑ ↑
1 = more coming actual value bits
0 = last character
3. Convert to Base64
Map each 6-bit value to a Base64 character:
A=0, B=1, C=2... Z=25, a=26, b=27... z=51, 0=52, 1=53... 9=61, +=62, /=63
Example
Lets go through the steps to encode the number 7:
That's why in our mapping example, the value 7 is encoded as 'O'!
Conclusion
I hope this deep dive into JavaScript source maps has shed light on how they function under the hood and adds to your appreciation for the amount of position data they efficiently encode.
P.S. Stay tuned: source maps support is coming to parca-agent and Polar Signals Cloud, bringing the same debugging magic to your performance profiling workflow!