Local Symbolication with Mozilla source-map Library
Offline stack trace resolution eliminates the round-trip to a hosted symbolication API and makes your debugging pipeline reproducible, auditable, and safe for air-gapped environments. This page covers the full lifecycle of the source-map npm package — WASM initialisation, SourceMapConsumer.originalPositionFor, batch processing, and memory teardown — building on the foundational concepts in Source Map Generation & Stack Trace Debugging and reaching across to Configuring Webpack for Production Source Maps for the artifact-generation side of the pipeline. Teams that need a hardened CLI wrapper should also read Securing Hidden Source Maps from Public Access to ensure the .map files feeding this consumer never become a public liability.
Concrete outcomes this page delivers:
- WASM initialisation pattern that works in Node.js 14–22 without a bundler
- Async
SourceMapConsumerconstruction with properawaitanddestroy()bookends originalPositionForcall with correct 1-based line / 0-based column semantics- Batch frame resolution with
Promise.allSettledand graceful null-mapping handling - Integration hook for caching consumers across multiple error events
- Failure mode table covering the five most common misconfiguration patterns
Problem Framing & Symptom Identification
A production JavaScript error arrives in your log aggregation system as a raw Error.stack string:
TypeError: Cannot read properties of undefined (reading 'id')
at e (https://cdn.example.com/app.a3f92b.js:1:48312)
at t.render (https://cdn.example.com/app.a3f92b.js:1:9047)
at HTMLButtonElement.<anonymous> (https://cdn.example.com/app.a3f92b.js:1:102841)
Every frame references line 1 because the production bundle is a single minified line. Column offsets are the only positional signal. Without a source map, each frame is a dead end — you know the bundle file but nothing about the original module, function name, or line.
The source-map npm package solves this with a WASM-accelerated VLQ decoder. It ingests the .map file that your build tool generated alongside the bundle and exposes SourceMapConsumer.originalPositionFor({ line, column }), which returns the pre-minification coordinates. The entire operation runs in-process: no HTTP calls, no SaaS tokens, no rate limits.
Common symptoms that drive engineers here:
- Error tracking dashboards display minified
e()ort()function names instead of original identifiers - CI/CD jobs symbolicate correctly in staging but fail in production because
.mapfiles are behind an authenticated CDN - SRE on-call runbooks require offline reproduction of a crash without contacting external services
- Compliance requirements forbid sending source code — even as map payloads — to third-party platforms
Prerequisites & Environment Setup
Node.js 14 or later is required. The package ships with a precompiled WebAssembly binary and resolves it from node_modules/source-map/lib/mappings.wasm. Older versions of the library (0.6.x) used a synchronous pure-JavaScript decoder; if you are on 0.6, upgrade to 0.7+ for the WASM path.
# Install the library — pin to 0.7.x for the async WASM consumer
npm install [email protected]
# Verify the WASM file shipped correctly
ls node_modules/source-map/lib/mappings.wasm
You also need a .map artifact co-located with or separately stored from the production bundle. If your Webpack config uses devtool: 'hidden-source-map', the .map files are generated but the bundle does not include a //# sourceMappingURL= comment pointing to them — you must track artifact paths yourself, typically by naming convention (bundle.hash.js → bundle.hash.js.map).
Prepare a sample .map file and a minified stack trace. The working examples below assume:
project/
├── dist/
│ ├── app.a3f92b.js ← minified bundle (no sourceMappingURL comment)
│ └── app.a3f92b.js.map ← source map artifact
└── symbolicate.js ← the script you are building
Step-by-Step Implementation
1. Initialise the WASM module
Call SourceMapConsumer.initialize() exactly once per process before constructing any consumer. In Node.js without a bundler, the library cannot locate the WASM binary automatically.
const sourceMap = require('source-map');
const path = require('path');
// One-time setup — tell the library where mappings.wasm lives on disk
sourceMap.SourceMapConsumer.initialize({
'lib/mappings.wasm': path.join(
__dirname,
'node_modules/source-map/lib/mappings.wasm'
)
});
This call is synchronous and lightweight — it only registers the path. The WASM binary is compiled lazily on first consumer construction. Calling initialize() more than once in the same process is safe (subsequent calls are ignored), so placing it in a shared module initialiser is fine.
2. Construct a SourceMapConsumer
In source-map v0.7+, the constructor is asynchronous and returns a Promise. Omitting await produces a Promise object, not a consumer — any method call on it throws immediately.
const fs = require('fs/promises');
async function buildConsumer(mapFilePath) {
const rawMap = await fs.readFile(mapFilePath, 'utf-8'); // read the .map JSON
// Must await — v0.7+ constructor is async (WASM compilation happens here)
const consumer = await new sourceMap.SourceMapConsumer(rawMap);
return consumer;
}
The rawMap string must be the full JSON content of the .map file — not a filename, not a parsed object. The constructor validates the version: 3 field and rejects malformed payloads with a descriptive error.
3. Call originalPositionFor
Pass { line, column } where line is 1-based and column is 0-based, exactly matching the values reported by browser Error.stack strings. No adjustment is needed between the two systems.
function resolveFrame(consumer, minLine, minColumn) {
const pos = consumer.originalPositionFor({
line: minLine, // 1-based — matches browser Error.stack directly
column: minColumn // 0-based — matches browser Error.stack directly
});
if (pos.source === null) {
// Generated code with no mapping (polyfill boundary, runtime injected code)
return { resolved: false, minLine, minColumn };
}
return {
resolved: true,
source: pos.source, // original file path as written in sources[]
line: pos.line, // original 1-based line
column: pos.column, // original 0-based column
name: pos.name ?? null // original symbol name, or null if stripped
};
}
A null return on pos.source is normal for runtime-injected code (webpack runtime chunk, polyfills, dynamic eval boundaries). Handle it gracefully — do not treat it as an error.
4. Parse a raw Error.stack string
Extract line and column numbers from the browser-formatted stack string before passing them to resolveFrame. V8 (Chrome, Node) format and SpiderMonkey (Firefox) format differ slightly.
// Parses V8-format stack frames: " at fn (file.js:LINE:COL)"
// Also handles anonymous: " at file.js:LINE:COL"
function parseV8Frame(frameStr) {
const match = frameStr.match(/\(([^)]+):(\d+):(\d+)\)$/) ||
frameStr.match(/at ([^\s]+):(\d+):(\d+)$/);
if (!match) return null;
return {
url: match[1],
line: parseInt(match[2], 10), // already 1-based
column: parseInt(match[3], 10) // already 0-based
};
}
function parseStack(stackStr) {
return stackStr
.split('\n')
.slice(1) // discard the "Error: message" first line
.map(line => parseV8Frame(line.trim()))
.filter(Boolean);
}
5. Resolve a full stack and destroy the consumer
Resolve all frames in a single consumer lifetime, then call consumer.destroy() to release the WASM memory buffer. Skipping destroy() causes the WASM heap to grow with each consumer instance and eventually triggers out-of-memory crashes in long-running processes.
async function symbolicateStack(rawStack, mapFilePath) {
const consumer = await buildConsumer(mapFilePath);
const frames = parseStack(rawStack);
const resolved = frames.map(f => resolveFrame(consumer, f.line, f.column));
consumer.destroy(); // mandatory — releases WASM heap allocation
return resolved;
}
Production Telemetry Integration
Local symbolication fits naturally into a log aggregation worker that enriches error events before they are written to your observability backend. The key architectural constraint is that SourceMapConsumer holds a WASM buffer — constructing one per error event at high throughput causes memory pressure. The solution is to cache consumers keyed by artifact path (or build hash) and share them across events within the same release.
A minimal integration with a hypothetical telemetry pipeline:
const consumerCache = new Map(); // simple in-process cache keyed by mapPath
async function getConsumer(mapPath) {
if (consumerCache.has(mapPath)) return consumerCache.get(mapPath);
const c = await buildConsumer(mapPath);
consumerCache.set(mapPath, c);
return c;
}
// Enrichment middleware for your error pipeline
async function enrichErrorEvent(event, mapPath) {
const consumer = await getConsumer(mapPath);
const frames = parseStack(event.stack);
event.originalFrames = frames.map(f => resolveFrame(consumer, f.line, f.column));
return event;
}
For bounded memory in a daemon that serves multiple releases, replace the plain Map with an LRU structure — see Caching Parsed Source Maps for Faster Symbolication for an implementation that evicts stale consumers and calls destroy() on eviction.
For a self-contained command-line tool that takes a raw stack and a .map file as arguments and prints resolved frames, see Symbolicating Stack Traces in a Node CLI Script.
Source content availability matters for telemetry enrichment. Check consumer.sourcesContent early: if every entry is null, the build was configured with nosources-source-map or the map was post-processed to strip content. You can still resolve coordinates, but you cannot inline original source snippets into error events.
function hasSourceContent(consumer) {
// Returns true only when at least one source file has inline content
return Array.isArray(consumer.sourcesContent) &&
consumer.sourcesContent.some(c => c !== null);
}
Source Map Internals & the VLQ Decoder
Understanding what the library actually does during originalPositionFor helps you predict failure modes and optimise accordingly. A Source Map v3 file is a JSON document with six top-level fields:
{
"version": 3,
"file": "app.3c9fa2.js",
"sourceRoot": "",
"sources": ["webpack://./src/components/Button.tsx", "webpack://./src/utils/events.ts"],
"sourcesContent": ["import React …", "export function dispatch …"],
"mappings": "AAAA,SAAS,EAAE,CAAC,CAAC;…"
}
The mappings value is a VLQ-encoded string. Each semicolon in the string deliminates a generated line (most production bundles have exactly one line, so you see one enormous segment before the first semicolon). Within a line, commas separate individual mapping segments. Each segment encodes up to five numbers in Base64-VLQ:
- Generated column (relative to the previous segment on the same line)
- Source file index (relative to previous)
- Original line (relative to previous)
- Original column (relative to previous)
- Names index (relative to previous, optional)
The WASM binary decodes all segments on first consumer construction and builds an internal sorted array. originalPositionFor then binary-searches this array for the segment whose generated column is closest to (but not exceeding) the requested column. That is the O(log n) operation.
This internal structure explains two important behaviours. First, looking up a column that falls between two segment boundaries returns the segment to the left — the last mapping before that position. This is why a slightly wrong column typically returns the correct function name but an adjacent source line. Second, looking up a column before the first segment on a line returns { source: null } — there is no mapping that covers that region, typically because it is Webpack’s runtime bootstrap code that the bundler generates rather than compiles from source.
You can inspect the raw decoded segments using consumer.eachMapping():
// Print the first 10 mappings to understand the coverage of a .map file
let count = 0;
consumer.eachMapping(m => {
if (count++ >= 10) return;
console.log(
`gen ${m.generatedLine}:${m.generatedColumn} → ` +
`${m.source}:${m.originalLine}:${m.originalColumn} (${m.name ?? ''})`
);
});
This is useful during debugging to verify that a .map file actually covers the column range you are looking up. If eachMapping shows no segments near column 48312, the map was generated from a different build.
The sourcesContent array is parallel to sources. Index 0 in sourcesContent is the full text of the file at index 0 in sources. Access it with consumer.sourceContentFor(originalSource) where originalSource is the exact string that appears in sources[]. Webpack typically prefixes paths with webpack://, so you must use the prefixed form:
// Retrieve inline source text for a resolved frame
const content = consumer.sourceContentFor(pos.source, /* returnNullOnMissing */ true);
if (content !== null) {
const lines = content.split('\n');
const snippet = lines.slice(
Math.max(0, pos.line - 3),
pos.line + 2
).join('\n');
console.log(`\nSource snippet around ${pos.source}:${pos.line}:\n${snippet}`);
}
Pass true as the second argument to sourceContentFor to get null instead of an exception when the content is missing. This matters when nosources-source-map was used during the build.
Verification & Testing
Write a deterministic test that constructs a known minified stack, resolves it, and asserts the expected original position. Pair this with a fixture .map file committed to the repository.
// test/symbolicate.test.js — works with Node's built-in test runner (v18+)
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { buildConsumer, resolveFrame } from '../symbolicate.js';
test('resolves known minified coordinate to original position', async () => {
const consumer = await buildConsumer('./test/fixtures/app.js.map');
const result = resolveFrame(consumer, 1, 4832);
assert.equal(result.resolved, true);
assert.match(result.source, /src\/components\/Button\.tsx/);
assert.ok(result.line > 0);
consumer.destroy(); // always clean up in tests too
});
Run the test in CI with node --test test/symbolicate.test.js. The fixture .map file is deterministic and version-controlled, so the assertion is reproducible across environments.
For integration testing against real production artifacts, pipe a captured error event through symbolicateStack and compare the output against a known-good symbolication from your error tracking dashboard. Any divergence indicates a build hash mismatch between the deployed bundle and the .map file being fed to the consumer.
Failure Modes & Edge Cases
| Scenario | Root Cause | Fix |
|---|---|---|
Error: wasm not loaded on first consumer construction |
SourceMapConsumer.initialize() was not called before new SourceMapConsumer() |
Call initialize() once at process startup before any consumer is constructed |
originalPositionFor returns { source: null } for every frame |
Map file was generated with a different build than the bundle being debugged | Verify build hashes match; regenerate both bundle and .map from the same build invocation |
| Resolved line numbers are consistently off by ±1 | Manual line adjustment applied before calling the API | Pass raw Error.stack line values directly — no adjustment needed between browser format and the library |
| Resolved column is inside the wrong token | Column was parsed from a 1-based source (e.g. some error reporters add 1) | Ensure columns are 0-based at the call site; subtract 1 if your parser produces 1-based columns |
consumer.originalPositionFor throws TypeError: consumer.originalPositionFor is not a function |
await was omitted on new SourceMapConsumer() in v0.7+ |
Add await; the constructor is async and returns a Promise, not a consumer instance |
| Heap grows unboundedly under sustained load | Consumers are constructed per-event without destroy() |
Call consumer.destroy() after each batch; cache consumers rather than constructing one per event |
FAQ
Can the source-map library run in a browser context?
Yes, but with additional setup. The WASM binary must be served over HTTP and the path passed to initialize() must be a URL, not a filesystem path. Node.js is preferable for local symbolication because filesystem access is direct and there are no CORS constraints on loading the binary. Browser usage makes sense only when you need client-side symbolication — for example, a DevTools extension.
How do I handle source maps where sourcesContent is empty?
Parse consumer.sources to get the original file path list, resolve each path relative to your project root, and read the files from disk. The library’s sourceContentFor(source) method returns null when content was stripped; fall back to fs.readFile for the resolved path. Build with nosources-source-map only when compliance requires it — otherwise keep sourcesContent populated to enable inline snippet enrichment in error events.
What is the actual performance of originalPositionFor at scale?
The lookup is O(log n) where n is the number of mappings in the mappings field. A typical production bundle with 50,000 mappings resolves a single frame in under 0.1 ms on modern hardware. The dominant cost at scale is constructing and destroying consumers, not the lookups themselves — which is why caching consumers across events is the correct optimisation path.
Does the library support source maps generated by Vite or esbuild?
Yes. Both tools emit standard Source Map v3 JSON, which this library consumes without any adapter. The only requirement is that the .map file includes a version: 3 field. Vite-generated maps with inline sourcesContent work identically to Webpack-generated ones.