Lookout
000 015 030 045 060 075 090 105 120 135 150 165 180 195 210 225 240 255 270 285 300 315 330 345 360
2 min read Tom Shafer Deep dive

Writing a Source Map v3 VLQ decoder from scratch

A deep dive on JavaScript symbolication — decoding Base64 VLQ mappings, the cumulative-vs-reset field rule, binary-searching segments by column, and matching minified frames to the right source map.

A production JavaScript error is gibberish: t.a is not a function at app.4f3a.js:1:88291. A source map turns that back into pay() at CheckoutForm.tsx:42:12. This is a deep dive into the decoder that does it — written from scratch, no dependencies.

What a source map actually is

A Source Map v3 file is JSON with four fields that matter:

  • sources — original file paths
  • names — original identifiers (function/variable names)
  • sourcesContent — the original source text (optional but lovely for context)
  • mappings — a Base64-VLQ string encoding the position translations

mappings is the whole game. It's a compact encoding of "generated position → original position," and decoding it is the only hard part.

Decoding VLQ

mappings is split by ; into generated lines, and each line by , into segments. Each segment is 1, 4, or 5 numbers, Base64-VLQ encoded:

[ generatedColumn, sourceIndex, originalLine, originalColumn, nameIndex ]

VLQ (Variable-Length Quantity) packs a signed integer into Base64 digits. Each digit contributes 5 bits; the 6th bit (value 32) is a continuation flag; the very first bit of the assembled value is the sign:

foreach (str_split($segment) as $char) {
    $digit = $base64[$char];
    $value += ($digit & 31) << $shift;
    if ($digit & 32) { $shift += 5; continue; }   // more digits follow
    $result = ($value >> 1) * (($value & 1) ? -1 : 1); // last bit = sign
    // emit $result, reset $value/$shift
}

The detail that breaks everything if you miss it

Every field is a delta, not an absolute value. But they don't all reset at the same time:

Only the generated column resets to 0 at the start of each new generated line. sourceIndex, originalLine, originalColumn, and nameIndex accumulate across the entire document.

Get this wrong — say, resetting originalLine per line — and every frame is off by a little, in a way that looks plausible and is completely wrong. A symbolicator that's subtly wrong is worse than none: it confidently points you at the wrong line. So this is exactly where the unit tests live, asserting against hand-encoded mappings I worked out by hand.

The lookup: binary search by column

After decoding, each generated line holds a list of segments sorted by generated column. To symbolicate a frame at (line, column):

  1. Grab that line's segment list (line - 1, zero-indexed).
  2. Binary search for the segment with the greatest generated column <= column.
  3. Return its sources[sourceIndex], originalLine + 1, originalColumn, and names[nameIndex].

One subtlety: V8 frame columns are 1-based, but the map's generated columns are 0-based, so you query with column - 1. Off-by-one here means every symbolicated column is wrong.

Matching a frame to the right map

Decoding is half the battle; finding which map applies is the other half. A frame says it came from app.4f3a.js. The match is by bundle basename within a release, with a pragmatic fallback: if a release has exactly one uploaded map, use it — which covers the extremely common case of content-hashed filenames (app.4f3a.js vs the app.js you uploaded). Maps arrive via an authenticated API endpoint (for CI) or a UI on the Releases page, and a backfill job re-symbolicates recent errors when a map lands late — because in practice the source-map upload races with the first crashes after a deploy.

The SDK half

None of this works without column numbers, and the browser SDK was discarding them. So it got bumped to capture column on every frame. Old clients keep working (they just don't symbolicate); new ones light up.

Next: making alerts actually page a human with on-call integrations.

deep-dive source-maps javascript symbolication vlq