Cross-Browser Stack Trace Normalization Techniques
Every JavaScript runtime serializes Error.stack differently, and those differences are deep enough to break any parser that assumes a single format. V8 (Chrome, Node.js, Edge) wraps frames in at keywords and parentheses; SpiderMonkey (Firefox) uses an @ separator with no surrounding syntax; JavaScriptCore (Safari) shares SpiderMonkey’s @ separator but adds its own async markers and global code placeholder. The gap widens further once you add async frames, eval boundaries, and anonymous closures. Without a normalization layer, error telemetry systems either silently drop frames or misparse coordinates, producing symbolication results that point to the wrong line.
This page is part of the Source Map Generation & Stack Trace Debugging guide. It assumes you are already comfortable with the basics covered in Configuring Webpack for Production Source Maps and sits alongside Cross-Browser Source Map and Stack Compatibility.
After working through this page you will be able to:
- Identify V8, SpiderMonkey, and JavaScriptCore stack formats by their structural signatures
- Use
error-stack-parseras a baseline, then extend it with a typedUnifiedStackFrameinterface - Preserve and classify async frame markers across promise continuations and
awaitboundaries - Feed normalized frames into
SourceMapConsumerfor accurate symbolication - Integrate a normalization pipeline into production error telemetry (Sentry, custom SDKs)
Problem Framing & Symptom Identification
The root issue is that Error.prototype.stack is not standardized by ECMAScript. Each engine vendor chose a different serialization format, and those choices have remained stable for years because changing them would break existing tooling. The three dominant formats are:
V8 (Chrome, Node.js, Edge, Deno)
Error: something went wrong
at fetchUser (https://app.example.com/bundle.js:12:345)
at async loadDashboard (https://app.example.com/bundle.js:8:102)
at async Promise.all (index 0)
The first line is ErrorType: message. Every frame begins with four spaces and the literal at. Anonymous closures appear as at <anonymous>. Async frames include at async before the function name. The location is wrapped in parentheses when a function name is present, or appears bare when it is not.
SpiderMonkey (Firefox)
fetchUser@https://app.example.com/bundle.js:12:345
loadDashboard@https://app.example.com/bundle.js:8:102
There is no Error: header line in the stack string itself. Each frame is functionName@url:line:col. Anonymous functions appear as an empty string before @, so the line starts with @. Async boundaries are not represented as separate lines; Firefox instead provides the Error.prototype.stack as a linear sequence without async keywords.
JavaScriptCore (Safari)
fetchUser@https://app.example.com/bundle.js:12:345
loadDashboard@https://app.example.com/bundle.js:8:102
global code@https://app.example.com/bundle.js:1:1
Structurally similar to SpiderMonkey, but Safari adds global code@ as a frame name for the top-level script scope, and sometimes emits @[native code] for built-in methods. Newer Safari versions (16+) also emit async frame markers as async* in some contexts.
Symptoms of an unnormalized pipeline
- Symbolication results point to minified column 0 even though a valid source map exists — caused by misidentifying the column token when the engine-specific regex runs against the wrong format
- Frame function names appear as
undefinedor empty string — caused by trying to apply the V8at fn (...)capture group pattern to a SpiderMonkeyfn@line - Async frames disappear from the ingested trace in your error tracker — caused by a filter that drops lines containing
asyncbefore parsing - Safari errors always have a spurious
global codeframe at the bottom — caused by not stripping the JavaScriptCore-specific placeholder
Prerequisites & Environment Setup
You need Node.js 18 or later and a project that can import ESM or CommonJS. The primary dependency is error-stack-parser, a well-maintained zero-dependency library that handles the three major formats as a baseline. You will also need source-map for the symbolication step.
# Install normalization and symbolication dependencies
npm install error-stack-parser source-map
# TypeScript types (if using TypeScript)
npm install --save-dev @types/error-stack-parser
Create a directory to hold the normalization utilities:
mkdir -p src/error-normalization
The examples below assume TypeScript with "moduleResolution": "bundler" or "node16". If you are using plain JavaScript, remove type annotations — the runtime logic is identical.
Step-by-Step Implementation
1. Define the unified frame interface
Before parsing anything, establish the canonical shape that all engine-specific parsers must produce. Locking this interface first prevents drift between parsers.
// src/error-normalization/types.ts
export type FrameType = 'sync' | 'async' | 'async_boundary' | 'native' | 'eval' | 'unknown';
export interface UnifiedStackFrame {
/** Original function name, or '<anonymous>' if absent */
functionName: string;
/** Absolute URL or file path; null for native frames */
fileName: string | null;
/** 1-based line number as reported by the engine */
lineNumber: number | null;
/** 0-based column number as reported by the engine */
columnNumber: number | null;
/** Semantic classification of this frame */
frameType: FrameType;
/** True when the frame was prefixed with 'async' or 'async*' */
isAsync: boolean;
/** Raw unparsed line, kept for debugging */
raw: string;
}
2. Detect the engine from a raw stack string
Rather than relying on navigator.userAgent (unavailable in Node.js, unreliable in some environments), detect the format from the stack string itself. V8 stacks contain the literal string at (four spaces, at, space). SpiderMonkey and JavaScriptCore stacks contain @ with no preceding at .
// src/error-normalization/detect-engine.ts
export type EngineFormat = 'v8' | 'spidermonkey' | 'jsc' | 'unknown';
/**
* Infer the JS engine format from the raw Error.stack string.
* Heuristic order matters: check V8 first because Node.js is common.
*/
export function detectEngineFormat(rawStack: string): EngineFormat {
// V8: frames start with whitespace + "at "
if (/^\s+at\s/m.test(rawStack)) return 'v8';
// JSCore: contains "global code@" which SpiderMonkey does not emit
if (/\bglobal code@/.test(rawStack)) return 'jsc';
// SpiderMonkey: frames contain "@" without "at " prefix
if (/@.+:\d+:\d+/.test(rawStack)) return 'spidermonkey';
return 'unknown';
}
3. Use error-stack-parser as the baseline parser
error-stack-parser handles the common cases for all three engines and returns a normalized StackFrame object array. Wrap it so you can extend or override its output:
// src/error-normalization/baseline-parse.ts
import ErrorStackParser from 'error-stack-parser';
import type { UnifiedStackFrame } from './types.js';
/**
* Run error-stack-parser and map its output to UnifiedStackFrame[].
* This covers ~85% of real-world stacks correctly.
*/
export function baselineParse(error: Error): UnifiedStackFrame[] {
try {
const frames = ErrorStackParser.parse(error);
return frames.map((f, i) => ({
functionName: f.functionName ?? '<anonymous>',
fileName: f.fileName ?? null,
lineNumber: f.lineNumber ?? null,
columnNumber: f.columnNumber ?? null,
frameType: 'sync', // refined in later steps
isAsync: false, // refined in later steps
raw: (error.stack ?? '').split('\n')[i + 1] ?? '',
}));
} catch {
// error-stack-parser throws on completely unrecognized formats
return [];
}
}
4. Build custom regex parsers for edge cases
error-stack-parser does not classify async frames or handle JSCore’s global code@ placeholder. Build engine-specific parsers that you can fall back to when baseline parsing misses frames. The child page Normalizing V8 and SpiderMonkey Stack Frames covers these patterns in depth.
// src/error-normalization/v8-parser.ts
import type { UnifiedStackFrame, FrameType } from './types.js';
// Matches: at [async] functionName (url:line:col)
// or: at [async] url:line:col
const V8_NAMED = /^\s+at\s+(async\s+)?(.+?)\s+\((.+):(\d+):(\d+)\)\s*$/;
const V8_BARE = /^\s+at\s+(async\s+)?(.+):(\d+):(\d+)\s*$/;
function classifyV8(fn: string): FrameType {
if (fn === 'native') return 'native';
if (fn.includes('eval')) return 'eval';
return 'sync';
}
export function parseV8Frames(rawStack: string): UnifiedStackFrame[] {
const lines = rawStack.split('\n');
const frames: UnifiedStackFrame[] = [];
for (const line of lines) {
const named = line.match(V8_NAMED);
if (named) {
const isAsync = Boolean(named[1]);
frames.push({
functionName: named[2].trim() || '<anonymous>',
fileName: named[3],
lineNumber: Number(named[4]),
columnNumber: Number(named[5]),
frameType: isAsync ? 'async' : classifyV8(named[2]),
isAsync,
raw: line,
});
continue;
}
const bare = line.match(V8_BARE);
if (bare) {
const isAsync = Boolean(bare[1]);
frames.push({
functionName: '<anonymous>',
fileName: bare[2],
lineNumber: Number(bare[3]),
columnNumber: Number(bare[4]),
frameType: isAsync ? 'async' : 'sync',
isAsync,
raw: line,
});
}
}
return frames;
}
// src/error-normalization/spidermonkey-jsc-parser.ts
import type { UnifiedStackFrame } from './types.js';
// Matches: functionName@url:line:col (SpiderMonkey and JSCore)
const MOZ_FRAME = /^(.*?)@(.*):(\d+):(\d+)\s*$/;
const JSC_NATIVE = /^\[native code\]$/;
const JSC_GLOBAL = /^global code$/i;
export function parseMozJscFrames(rawStack: string): UnifiedStackFrame[] {
const lines = rawStack.split('\n').filter(Boolean);
const frames: UnifiedStackFrame[] = [];
for (const line of lines) {
const m = line.match(MOZ_FRAME);
if (!m) continue;
const fnRaw = m[1].trim();
const fileRaw = m[2];
const lineNo = Number(m[3]);
const colNo = Number(m[4]);
// Skip JSCore native frames and the top-level global code placeholder
if (JSC_NATIVE.test(fileRaw)) continue;
const isGlobal = JSC_GLOBAL.test(fnRaw);
frames.push({
functionName: isGlobal ? '<global>' : (fnRaw || '<anonymous>'),
fileName: fileRaw || null,
lineNumber: lineNo,
columnNumber: colNo,
frameType: isGlobal ? 'unknown' : 'sync',
isAsync: fnRaw.includes('async') || fnRaw.includes('async*'),
raw: line,
});
}
return frames;
}
5. Classify async frame markers and async boundaries
V8 injects an at async Promise.all (index 0) line between async continuations. This line has no meaningful file location but must be preserved so the frame chain remains readable. See Handling Anonymous and Eval Frames in Stack Traces for the eval-specific variant.
// src/error-normalization/classify-async.ts
import type { UnifiedStackFrame } from './types.js';
const V8_ASYNC_BOUNDARY = /at async Promise\.(all|allSettled|race|any)/;
/**
* Post-process a parsed frame array to label async boundaries
* and mark continuation frames that follow them.
*/
export function classifyAsyncFrames(frames: UnifiedStackFrame[]): UnifiedStackFrame[] {
let sawBoundary = false;
return frames.map(frame => {
if (V8_ASYNC_BOUNDARY.test(frame.raw)) {
sawBoundary = true;
return { ...frame, frameType: 'async_boundary', isAsync: true };
}
if (frame.isAsync) {
sawBoundary = false; // explicit async frame resets boundary tracking
return { ...frame, frameType: 'async' };
}
if (sawBoundary) {
// frames immediately after a Promise.all boundary are continuations
return { ...frame, frameType: 'async' };
}
return frame;
});
}
6. Assemble the normalization pipeline
Wire the detector, parsers, and classifier into a single function that accepts a raw Error and returns UnifiedStackFrame[]:
// src/error-normalization/normalize.ts
import { detectEngineFormat } from './detect-engine.js';
import { baselineParse } from './baseline-parse.js';
import { parseV8Frames } from './v8-parser.js';
import { parseMozJscFrames } from './spidermonkey-jsc-parser.js';
import { classifyAsyncFrames } from './classify-async.js';
import type { UnifiedStackFrame } from './types.js';
export function normalizeStack(error: Error): UnifiedStackFrame[] {
const rawStack = error.stack ?? '';
if (!rawStack) return [];
const engine = detectEngineFormat(rawStack);
let frames: UnifiedStackFrame[];
switch (engine) {
case 'v8':
frames = parseV8Frames(rawStack);
break;
case 'spidermonkey':
case 'jsc':
frames = parseMozJscFrames(rawStack);
break;
default:
// Fall back to error-stack-parser for unknown formats
frames = baselineParse(error);
}
return classifyAsyncFrames(frames);
}
Production Telemetry Integration
A normalized UnifiedStackFrame[] is the correct unit to hand off to both source map symbolication and error tracking systems.
Feeding frames into SourceMapConsumer
The source-map library’s originalPositionFor method expects { line, column } where line is 1-based and column is 0-based — exactly the values that all three engines report. No offset arithmetic is needed. The Local Symbolication with the Mozilla Source Map Library page covers the full symbolication workflow; the integration point is:
// src/error-normalization/symbolicate.ts
import { SourceMapConsumer } from 'source-map';
import type { UnifiedStackFrame } from './types.js';
// Simple LRU-style cache keyed on source map URL
const consumerCache = new Map<string, SourceMapConsumer>();
async function getConsumer(mapUrl: string): Promise<SourceMapConsumer> {
if (consumerCache.has(mapUrl)) return consumerCache.get(mapUrl)!;
const res = await fetch(mapUrl);
const raw = await res.json();
const consumer = await new SourceMapConsumer(raw);
consumerCache.set(mapUrl, consumer);
return consumer;
}
export interface SymbolicatedFrame extends UnifiedStackFrame {
originalFileName: string | null;
originalLineNumber: number | null;
originalColumnNumber: number | null;
originalFunctionName: string | null;
}
export async function symbolicateFrames(
frames: UnifiedStackFrame[],
getMapUrl: (fileUrl: string) => string | null,
): Promise<SymbolicatedFrame[]> {
return Promise.all(
frames.map(async (frame): Promise<SymbolicatedFrame> => {
if (!frame.fileName || !frame.lineNumber) {
return { ...frame, originalFileName: null, originalLineNumber: null, originalColumnNumber: null, originalFunctionName: null };
}
const mapUrl = getMapUrl(frame.fileName);
if (!mapUrl) {
return { ...frame, originalFileName: null, originalLineNumber: null, originalColumnNumber: null, originalFunctionName: null };
}
try {
const consumer = await getConsumer(mapUrl);
const pos = consumer.originalPositionFor({
line: frame.lineNumber, // already 1-based
column: frame.columnNumber ?? 0, // already 0-based
});
return {
...frame,
originalFileName: pos.source,
originalLineNumber: pos.line,
originalColumnNumber: pos.column,
originalFunctionName: pos.name,
};
} catch {
return { ...frame, originalFileName: null, originalLineNumber: null, originalColumnNumber: null, originalFunctionName: null };
}
})
);
}
Sending normalized frames to Sentry
Sentry accepts a StackFrame[] shape that maps cleanly to UnifiedStackFrame. Use Sentry.captureEvent with a pre-built event rather than captureException, so you can supply already-normalized frames instead of letting the SDK re-parse the raw stack string (which it does using its own internal parser that may differ from yours):
import * as Sentry from '@sentry/browser';
import { normalizeStack } from './error-normalization/normalize.js';
import type { UnifiedStackFrame } from './error-normalization/types.js';
function toSentryFrame(f: UnifiedStackFrame): Sentry.StackFrame {
return {
function: f.functionName,
filename: f.fileName ?? undefined,
lineno: f.lineNumber ?? undefined,
colno: f.columnNumber ?? undefined,
in_app: f.fileName ? !f.fileName.includes('node_modules') : false,
};
}
export function captureNormalized(error: Error, extra?: Record<string, unknown>): void {
const frames = normalizeStack(error);
Sentry.captureEvent({
exception: {
values: [{
type: error.name,
value: error.message,
stacktrace: {
frames: frames.map(toSentryFrame).reverse(), // Sentry wants innermost last
},
}],
},
extra,
});
}
Custom SDK integration
For a custom telemetry SDK, serialize frames to a compact JSON payload and POST it to your ingestion endpoint. Include the raw field in development builds so you can audit the parser output against the original string:
export function serializeFrames(frames: UnifiedStackFrame[]): string {
return JSON.stringify(frames.map(f => ({
fn: f.functionName,
file: f.fileName,
line: f.lineNumber,
col: f.columnNumber,
type: f.frameType,
async: f.isAsync,
})));
}
Verification & Testing
Collect real stack strings from each browser and store them as test fixtures. Do not generate test stacks from the test runner environment, because the test runner itself may be V8 regardless of which browser you intend to test against.
// src/error-normalization/__tests__/normalize.test.ts
import { describe, it, expect } from 'vitest';
import { normalizeStack } from '../normalize.js';
// Fixtures collected from real browsers
const V8_STACK = `Error: test
at fetchUser (https://app.example.com/bundle.js:12:345)
at async loadDashboard (https://app.example.com/bundle.js:8:102)`;
const MOZ_STACK = `fetchUser@https://app.example.com/bundle.js:12:345
loadDashboard@https://app.example.com/bundle.js:8:102`;
const JSC_STACK = `fetchUser@https://app.example.com/bundle.js:12:345
global code@https://app.example.com/bundle.js:1:1`;
function fakeError(stack: string): Error {
const e = new Error('test');
e.stack = stack;
return e;
}
describe('normalizeStack', () => {
it('parses V8 frames correctly', () => {
const frames = normalizeStack(fakeError(V8_STACK));
expect(frames[0].functionName).toBe('fetchUser');
expect(frames[0].lineNumber).toBe(12);
expect(frames[0].columnNumber).toBe(345);
expect(frames[1].isAsync).toBe(true);
});
it('parses SpiderMonkey frames correctly', () => {
const frames = normalizeStack(fakeError(MOZ_STACK));
expect(frames[0].functionName).toBe('fetchUser');
expect(frames[0].lineNumber).toBe(12);
});
it('strips JSC global code frame', () => {
const frames = normalizeStack(fakeError(JSC_STACK));
const hasGlobalCode = frames.some(f => f.raw.includes('global code'));
// global code frames are parsed as <global> function name, not dropped
expect(frames.find(f => f.functionName === '<global>')).toBeDefined();
// but real fetchUser frame is present
expect(frames[0].functionName).toBe('fetchUser');
});
it('returns empty array for empty stack', () => {
const e = new Error('x');
e.stack = '';
expect(normalizeStack(e)).toHaveLength(0);
});
});
Run the suite with:
npx vitest run src/error-normalization/__tests__/normalize.test.ts
Cross-browser verification requires running the normalization code in real browsers. Use Playwright to collect actual Error.stack output from Chrome, Firefox, and Safari and save them as golden fixtures. Re-run the parser suite against those fixtures in CI to catch regressions when you update the regex patterns.
Failure Modes & Edge Cases
| Scenario | Root Cause | Fix |
|---|---|---|
V8 async Promise.all (index 0) line causes parse failure |
No file/line/col in the line, so regex fails to produce a frame | Match the pattern separately and emit a synthetic async_boundary frame with fileName: null |
SpiderMonkey anonymous function parsed as empty functionName |
The @ delimiter appears at line start; capture group returns empty string |
Default empty capture to '<anonymous>' before pushing the frame |
Safari [native code] frames corrupt symbolication |
source-map cannot look up native frames and throws |
Filter frames where fileName matches /\[native code\]/ before symbolication |
| Column offset off by one in symbolication output | Consumer called with column - 1 thinking engines are 1-based for columns |
Engines report 0-based columns; pass the raw value directly to originalPositionFor |
Error.stack unavailable in some environments |
Older mobile WebViews and some server-side runtimes omit the property | Guard with if (!error.stack) return [] before any parsing |
Eval frames contain nested location strings like eval at fn (url:1:1) |
Regex for normal frames does not account for the eval at prefix |
Detect eval at prefix separately and recurse to extract the outer location; see the dedicated child page on eval frames |
FAQ
Why not just use error-stack-parser for everything and skip the custom regex work?
error-stack-parser handles the common cases well, but it does not classify async frames, does not suppress JSC global code placeholders, and does not expose a frameType field. For a simple application that only needs file/line/col, it is sufficient. For production telemetry that routes async errors differently or needs to render async boundaries in a timeline UI, you need the custom classification layer built on top.
Do I need to adjust column numbers between engines before calling SourceMapConsumer?
No. All three engines — V8, SpiderMonkey, and JavaScriptCore — report 0-based column numbers in Error.stack. The source-map library’s originalPositionFor also expects 0-based columns. Pass the raw column value directly and make no adjustment. The common mistake of subtracting 1 produces off-by-one symbolication errors.
How do I handle stack traces from Web Workers, where the URL scheme differs?
Frames from a Worker context often have blob: or chrome-extension: URLs. The getMapUrl resolver function is the right place to normalize these: strip blob: prefixes, rewrite extension URLs to their registered source URL, and then apply the standard .js → .js.map convention. The symbolication code itself does not need to change.
Can this normalization pipeline run on the server side for Node.js errors?
Yes, with one adjustment: Node.js stacks always use the V8 format, so the engine detection step will always return 'v8'. You can skip the detector entirely in a Node.js-only context and call parseV8Frames directly. The source-map library and the UnifiedStackFrame interface are both environment-agnostic. The Understanding Source Map v3 Specification and Formats page covers the VLQ mapping format that underpins the symbolication step on both client and server.
Related
- Normalizing V8 and SpiderMonkey Stack Frames — deep-dive into regex patterns and named capture groups for each engine
- Handling Anonymous and Eval Frames in Stack Traces — strategies for eval boundaries,
<anonymous>, and dynamically constructed functions - Local Symbolication with the Mozilla Source Map Library — complete SourceMapConsumer workflow for mapping minified coordinates back to original sources
- Debugging Minified Code Without Source Maps — fallback techniques when source maps are unavailable or incomplete
- Core JavaScript Error Handling Boundaries — the broader error handling context that feeds into stack trace collection