Resolving Original Columns with SourceMapConsumer

When a minified frame resolves to the right source file and line but the column lands one token to the left or right of the real throw site, the culprit is almost always the bias argument you did not pass to originalPositionFor. This how-to sits under Local Symbolication with the Mozilla source-map Library and belongs to the wider Source Map Generation & Stack Trace Debugging pipeline; it focuses on one narrow but maddening problem: getting the exact original column back for a single frame, every time, regardless of whether your generated column falls precisely on a mapping boundary or somewhere between two of them.

Symptom / Trigger

You symbolicate a production stack, the source file and line are correct, but the reported column points at the wrong identifier — the assignment target instead of the property access that actually threw, or the closing paren of a call instead of its callee. The generated frame you feed in looks like this:

TypeError: Cannot read properties of undefined (reading 'total')
    at o (https://cdn.example.com/app.7f2a.js:1:24817)

Your lookup returns a plausible-but-wrong location:

consumer.originalPositionFor({ line: 1, column: 24817 });
// → { source: 'src/cart/summary.ts', line: 88, column: 2, name: 'render' }
// column 2 is the indentation — the real access `order.total` starts at column 18

The line is right. The column is useless. Setting a breakpoint at column 2 of line 88 drops you on whitespace, and any tooling that highlights the offending token highlights the wrong one. You can confirm the frame is only off by a column, not a line, by opening the original file at line 88 and counting characters: the reported column 2 sits in the leading indentation, while the property access that produced the undefined read starts sixteen characters later. This is subtly worse than a hard failure, because nothing in the output warns you the column is approximate — the frame reads as fully resolved. Engineers scanning a symbolicated trace trust the line:column pair implicitly, so a silently shifted column sends them looking at the wrong expression on the right line, which can burn an afternoon before anyone questions whether the number is even correct. The problem is especially common with aggressively minified bundles where multiple statements collapse onto a single generated line and the compiler emits sparse column mappings.

Root Cause Explanation

A source map does not store a mapping for every character of the generated file — only for the segments the compiler chose to emit, typically at statement and token boundaries. The mappings field is a comma-and-semicolon-delimited string of VLQ-encoded segments; each segment records a generated column and, relative to previous segments, the original file index, original line, and original column. Between two adjacent segments there is nothing: the generated columns in that gap simply have no direct mapping of their own. When your queried generated column falls between two recorded segments, originalPositionFor has to decide which neighbouring segment to attribute it to. Its default is SourceMapConsumer.GREATEST_LOWER_BOUND, meaning it snaps to the closest mapping whose generated column is less than or equal to yours. If the nearest useful mapping actually sits just after your column, the greatest-lower-bound match walks backwards past it and returns the previous, unrelated token.

The reason this matters for column recovery specifically — rather than line recovery — is that lines are dense and columns are sparse. Almost every original line has at least one mapping, so the line you get back is reliable. Columns within a heavily minified line, by contrast, may have only a handful of mappings covering dozens of tokens, so the odds that your exact generated column has its own segment are low. That mismatch between how error reporters capture columns (precise, per-character offsets from Error.stack) and how minifiers emit them (coarse, per-statement) is the entire root of the discrepancy.

// BROKEN — relies on the default bias and silently snaps to the wrong side
const pos = consumer.originalPositionFor({ line: 1, column: 24817 });
// no `bias` key → GREATEST_LOWER_BOUND → resolves to the token BEFORE 24817

The fix is to make the bias explicit and, when the greatest-lower-bound answer is wrong, retry with LEAST_UPPER_BOUND, which snaps forward to the nearest mapping at or after your column.

Default bias vs explicit biasA red panel shows the default greatest lower bound snapping backward to the wrong token, and a green panel shows least upper bound snapping forward to the correct column.DEFAULT BIASsnaps backwardlands on indentcolumn 2 (wrong)EXPLICIT BIASsnaps forwardlands on tokencolumn 18 (right)

Step-by-Step Fix

The strategy is to stop trusting a single lookup. You query the greatest-lower-bound match, judge whether it is strong, and when it is not, snap forward with the least-upper-bound bias and keep whichever of the two mappings sits physically nearer your generated column. Each numbered step below is independently runnable; assemble them into one module and export resolveColumn for your symbolication harness to call per frame.

1. Load the map and construct the consumer asynchronously

// source-map 0.7+ constructor is async and must be awaited
const sourceMap = require('source-map');
const raw = require('fs').readFileSync('dist/app.7f2a.js.map', 'utf-8');
const consumer = await new sourceMap.SourceMapConsumer(raw);

2. Query first with the default greatest-lower-bound bias

// GREATEST_LOWER_BOUND is the default, but pass it explicitly for clarity
const lower = consumer.originalPositionFor({
  line: 1, column: 24817, bias: sourceMap.SourceMapConsumer.GREATEST_LOWER_BOUND
});

3. Detect a weak match and re-query with least-upper-bound

// If the lower-bound match has no name or a null source, try snapping forward
function resolveColumn(consumer, line, column) {
  const lower = consumer.originalPositionFor({ line, column,
    bias: sourceMap.SourceMapConsumer.GREATEST_LOWER_BOUND });
  if (lower.source && lower.name) return lower;      // strong match, keep it
  const upper = consumer.originalPositionFor({ line, column,
    bias: sourceMap.SourceMapConsumer.LEAST_UPPER_BOUND });
  return upper.source ? upper : lower;               // fall back if upper is null
}

4. Pick the mapping physically closest to the generated column

// When both bounds resolve, choose the one nearer the original query column
function pickNearest(consumer, line, column) {
  const lo = consumer.originalPositionFor({ line, column, bias: 2 }); // GREATEST_LOWER_BOUND = 2
  const hi = consumer.originalPositionFor({ line, column, bias: 1 }); // LEAST_UPPER_BOUND = 1
  if (!lo.source) return hi;
  if (!hi.source) return lo;
  return (column - lo.column) <= (hi.column - column) ? lo : hi;
}

5. Use the resolved position to seed a token highlight

// pos.column is 0-based; add 1 for human-facing "line:col" display
const pos = resolveColumn(consumer, 1, 24817);
console.log(`${pos.source}:${pos.line}:${pos.column + 1} (${pos.name})`);
// → src/cart/summary.ts:88:18 (render)

Dual-bias resolution pipelineA horizontal three-stage flow: a generated column enters, both bias directions are queried, and the nearest mapping is chosen as output.generated colquery both biasnearest mapping

Verification

Prove the resolved column lands on the real token by asserting against a fixture whose original coordinates you know:

// verify-column.js — run: node verify-column.js
const assert = require('assert');
const pos = resolveColumn(consumer, 1, 24817);

assert.strictEqual(pos.source, 'src/cart/summary.ts');
assert.strictEqual(pos.line, 88);
assert.strictEqual(pos.column, 17);          // 0-based; the `o` of `order.total`
assert.strictEqual(pos.name, 'render');
console.log('Column resolution verified — landed on the throwing token');

If the assertion on pos.column passes, the bias logic picked the forward mapping correctly. Run this in CI against a committed .map fixture so a future source-map upgrade that changes default-bias behaviour fails loudly instead of silently shifting every column. The fixture should be a real build artifact, not a hand-written map — synthetic maps rarely reproduce the sparse-column gaps that trigger the bug in the first place, so a test built on them can pass while production still resolves to the wrong token.

For a stronger signal, assert on the resolved token text rather than the bare column number. Read the original source out of sourcesContent, slice from pos.column, and confirm the substring begins with the identifier you expect. That decouples the test from exact offsets that can legitimately shift by a character when you upgrade your bundler, while still catching a mapping that lands on the wrong expression entirely.

Edge Cases & Gotchas

  • The bias constants are plain numbers. GREATEST_LOWER_BOUND is 2 and LEAST_UPPER_BOUND is 1. If you hard-code the wrong integer you invert your logic with no error, so always reference the named constants off SourceMapConsumer.
  • A null source means no mapping on either side. When both bounds return { source: null }, the generated column sits in unmapped runtime code — do not retry endlessly; report the frame as unresolved and move on.
  • bias only affects column selection within the requested line. It never changes which line you get. If the line itself is wrong, the problem is a stale map, not bias — see the caching notes in Caching Parsed Source Maps for Faster Symbolication.
  • Column 0 is a legitimate value, not a missing one. Some reporters normalise unknown columns to 0; treat column === 0 as a real query and let the bias logic resolve it rather than special-casing it as absent.

Bias fallback decision flowA stacked three-stage flow: query greatest lower bound, test whether the match is strong, and fall back to least upper bound when it is not.query lower boundname and source set?else try upper bound

FAQ

Why does the default bias snap backward instead of to the nearest mapping?

The default GREATEST_LOWER_BOUND exists because a generated column almost always falls at or just after the start of a token, so the closest preceding mapping is usually correct. The failure case is minifiers that omit a mapping at the exact throw offset; then the preceding mapping belongs to an earlier token and you need LEAST_UPPER_BOUND to reach forward. There is no single bias that is right for every map, which is why querying both and choosing the nearer result is the robust approach.

Is calling originalPositionFor twice per frame a performance problem?

Rarely. Each lookup is a binary search over the decoded mappings for that line, so two calls double a cost that is already logarithmic and small. If you symbolicate millions of frames, cache the parsed consumer rather than the individual lookups — parsing dominates, not querying. The Symbolicating Stack Traces in a Node CLI Script how-to shows the surrounding harness where this cost is measured.

Does bias change the name field I get back?

Yes, indirectly. The name is attached to whichever mapping the bias selects, so a backward snap can return a stale identifier or null while a forward snap returns the real function name. That is exactly why the step-by-step fix treats a missing name as the signal to retry with the opposite bias. Keep in mind that name is populated only when the original mapping carried an identifier — arrow-function expressions and object-literal methods often map to segments with no name at all, so a null name is not always proof of a bad column. Combine the name check with a source and line sanity test before deciding a match is weak, and you will avoid needless second lookups on frames that were fine all along.