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-parser as a baseline, then extend it with a typed UnifiedStackFrame interface
  • Preserve and classify async frame markers across promise continuations and await boundaries
  • Feed normalized frames into SourceMapConsumer for accurate symbolication
  • Integrate a normalization pipeline into production error telemetry (Sentry, custom SDKs)

Cross-Browser Stack Trace Normalization Data Flow Three raw Error.stack inputs from V8, SpiderMonkey, and JavaScriptCore feed into an engine detector, then into engine-specific parsers, which merge into a unified StackFrame array, and finally pass through a SourceMapConsumer symbolication step to produce original source locations. V8 Error.stack at fn (file:line:col) SpiderMonkey fn@file:line:col JavaScriptCore @file:line:col Engine Detector regex heuristics V8 Parser named captures SpiderMonkey Parser JSCore Parser async markers UnifiedStackFrame[] fn · file · line · col frameType · isAsync SourceMapConsumer originalPositionFor()

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 undefined or empty string — caused by trying to apply the V8 at fn (...) capture group pattern to a SpiderMonkey fn@ line
  • Async frames disappear from the ingested trace in your error tracker — caused by a filter that drops lines containing async before parsing
  • Safari errors always have a spurious global code frame 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.