Cross-Browser Source Map and Stack Compatibility

Engine differences in Error.stack formatting, column-offset conventions, and async-frame markers cause silent symbolication failures that are invisible in development and catastrophic in production. Chrome’s V8, Firefox’s SpiderMonkey/Gecko, and Safari’s JavaScriptCore each produce structurally distinct stack strings — and those distinctions propagate all the way to source map lookup coordinates, making a single shared parser insufficient. This page maps every engine-specific deviation, explains how each one affects symbolication, and builds a unified compatibility layer that runs correctly in all three engines.

For the broader build and upload context that gives this page its foundation, see Source Map Generation & Stack Trace Debugging. For the complementary problem of normalizing stack strings across engines after capture, see Cross-Browser Stack Trace Normalization Techniques.

After working through this guide you will be able to:

  • Identify the exact frame syntax emitted by each of the three major browser engines
  • Explain why column numbers and line counts differ between engines and how to compensate
  • Parse Safari’s headerless, global code format reliably without false positives
  • Reconstruct Firefox’s async causal chains across await boundaries
  • Feed normalized coordinates into SourceMapConsumer.originalPositionFor correctly for every engine
  • Test your parser against a synthetic multi-engine fixture without requiring real browsers
Cross-Browser Stack Format to Symbolication Pipeline Three browser engines (V8/Chrome, SpiderMonkey/Firefox, JavaScriptCore/Safari) each emit different Error.stack formats. A detection layer routes each format to an engine-specific parser, then a normalizer converts all outputs to a shared StackFrame schema before passing coordinates to SourceMapConsumer. V8 / Chrome at fn (file:line:col) SpiderMonkey / FF fn@file:line:col JavaScriptCore / Safari fn@file:line:col (no header) Engine Detector regex heuristics Engine Parsers parseV8Frame() parseMozFrame() parseJSCFrame() parseAsyncChain() SMC lookup Raw Error.stack strings Original source routes by format {fn, file, line, col} Column-offset hazard: JSC reports 1-based cols subtract 1 before SourceMapConsumer lookup

Problem Framing & Symptom Identification

The symptom is consistent: an error tracking dashboard reports minified frames for Safari or Firefox users while the same error symbolcates correctly for Chrome users. The shared source map file is identical. The shared upload pipeline succeeded. The issue is never the source map itself — it is the coordinate extracted from the stack string.

Three failure patterns repeat across production observability setups:

Engine format mismatch. A parser written for V8’s at functionName (file:line:col) syntax receives a Safari frame like functionName@https://example.com/app.js:12:8 and extracts nothing, silently dropping the frame or returning null coordinates. Symbolication never runs.

Column-offset divergence. Safari’s JavaScriptCore historically reports 1-based column numbers in Error.stack, while V8 and SpiderMonkey report 0-based. Passing a JSC column directly to SourceMapConsumer.originalPositionFor — which expects 0-based columns — produces a result that is consistently one character to the right of the actual source location. In minified bundles where multiple identifiers share the same line, a one-column offset resolves to the wrong symbol entirely.

Async chain truncation. Firefox’s SpiderMonkey inserts an async* marker between frames when the stack crosses an await boundary. Parsers that split on newlines without recognizing this marker treat the marker line as a malformed frame, break the array at that index, and discard the causal portion of the chain.

Each failure produces a silent degradation rather than an explicit error. Symbolication returns partial data; dashboards group unresolved frames under a minified bucket; no alert fires.

Prerequisites & Environment Setup

You need Node.js 18+ for the source-map consumer and the test harness. Install dependencies:

npm install source-map            # Mozilla's SourceMapConsumer
npm install -D vitest             # test runner for the fixture suite

The parser module developed in this guide has zero runtime dependencies beyond source-map for the symbolication step. The detection and parsing logic uses only standard RegExp operations available in all modern engines.

Confirm your error tracking SDK (Sentry, Datadog RUM, or a custom ingestion endpoint) exposes raw error.stack strings before any transformation. If the SDK strips frames before forwarding them to your server, the compatibility fixes below must be applied client-side before the SDK call, not server-side during symbolication.

Step-by-Step Implementation

Step 1 — Detect the engine from the stack string

Reliable engine detection does not require navigator.userAgent. The stack format itself is self-identifying. V8 always starts with the error message on the first line (Error: message\n). SpiderMonkey and JavaScriptCore both use the @ delimiter, but SpiderMonkey includes async* markers while JavaScriptCore does not. JavaScriptCore omits the Error: header line entirely for errors thrown in global scope.

// Engine detection from raw Error.stack — no userAgent needed
export type Engine = 'v8' | 'spidermonkey' | 'jsc' | 'unknown';

export function detectEngine(stack: string): Engine {
  if (!stack) return 'unknown';

  // V8: first line is always "ErrorType: message" followed by "at " frames
  if (/^\w+Error:/.test(stack) && /\n\s+at /.test(stack)) return 'v8';

  // SpiderMonkey: uses @ separator AND may include "async*" markers
  if (stack.includes('@') && (stack.includes('async*') || /\w+@https?:/.test(stack))) {
    return 'spidermonkey';
  }

  // JSC: uses @ separator but NO "Error:" header line
  if (stack.includes('@') && !/^\w+Error:/.test(stack)) return 'jsc';

  return 'unknown';
}

The async* check must precede the JSC check because a SpiderMonkey async stack also lacks the Error: prefix for individual async segments.

Step 2 — Parse V8 frames

V8 produces two frame variants: named frames (at functionName (url:line:col)) and anonymous frames (at url:line:col). Both also appear with an async prefix for awaited positions.

const V8_NAMED = /^\s*at\s+(?:async\s+)?(.+?)\s+\((.+):(\d+):(\d+)\)$/;
const V8_ANON  = /^\s*at\s+(?:async\s+)?(https?:\/\/.+):(\d+):(\d+)$/;

export interface NormalizedFrame {
  fn: string;
  file: string;
  line: number;
  col: number;        // always 0-based after normalization
  isAsync: boolean;
}

export function parseV8Stack(stack: string): NormalizedFrame[] {
  return stack
    .split('\n')
    .slice(1)          // drop the "Error: message" header line
    .map((raw): NormalizedFrame | null => {
      const named = raw.match(V8_NAMED);
      if (named) return {
        fn: named[1], file: named[2],
        line: Number(named[3]), col: Number(named[4]),  // already 0-based
        isAsync: raw.trimStart().startsWith('at async'),
      };
      const anon = raw.match(V8_ANON);
      if (anon) return {
        fn: '<anonymous>', file: anon[1],
        line: Number(anon[2]), col: Number(anon[3]),
        isAsync: raw.trimStart().startsWith('at async'),
      };
      return null;
    })
    .filter((f): f is NormalizedFrame => f !== null);
}

V8 columns are 0-based throughout, matching SourceMapConsumer’s expectation. No offset adjustment is needed.

Step 3 — Parse SpiderMonkey/Gecko frames

SpiderMonkey separates function name from location with @ and uses functionName@url:line:col syntax. The async* marker appears as a bare line containing only async* between causal frame groups — it is not a frame itself.

// SpiderMonkey: fn@url:line:col (or @url:line:col for anonymous)
const MOZ_FRAME = /^(.*?)@(.*):(\d+):(\d+)$/;

export function parseSpiderMonkeyStack(stack: string): NormalizedFrame[] {
  const frames: NormalizedFrame[] = [];

  for (const raw of stack.split('\n')) {
    const trimmed = raw.trim();
    if (!trimmed || trimmed === 'async*') continue;  // skip markers and blanks

    const m = trimmed.match(MOZ_FRAME);
    if (!m) continue;

    frames.push({
      fn: m[1] || '<anonymous>',
      file: m[2],
      line: Number(m[3]),
      col: Number(m[4]),      // SpiderMonkey: 0-based columns
      isAsync: false,          // async context tracked separately via markers
    });
  }
  return frames;
}

SpiderMonkey also reports 0-based columns, so no adjustment is required for SourceMapConsumer.

Step 4 — Parse JavaScriptCore/Safari frames

JSC is the most divergent engine. It uses the same fn@url:line:col syntax as SpiderMonkey but with two critical differences: it omits the Error: message header entirely for errors thrown in global scope, and it historically uses 1-based column numbers in Safari 15 and earlier. Safari 16+ aligns with 0-based columns, but you cannot know the Safari version from the stack string alone, so the safest production strategy is to treat JSC columns as 1-based and subtract 1 before lookup.

JSC also inserts global code as the function name for frames executing outside any function, which has no V8 equivalent.

const JSC_FRAME = /^(.*?)@(.*):(\d+):(\d+)$/;

// Safari/JSC version heuristic: pass isSafari16Plus = true only when
// you can derive it from User-Agent on the server side.
export function parseJSCStack(stack: string, isSafari16Plus = false): NormalizedFrame[] {
  return stack
    .split('\n')
    .map((raw): NormalizedFrame | null => {
      const trimmed = raw.trim();
      if (!trimmed) return null;

      // Skip the "Error: message" header if present (some JSC versions include it)
      if (/^\w+Error:/.test(trimmed) && !trimmed.includes('@')) return null;

      const m = trimmed.match(JSC_FRAME);
      if (!m) return null;

      const rawCol = Number(m[4]);
      const col = isSafari16Plus ? rawCol : Math.max(0, rawCol - 1); // normalize to 0-based

      return {
        fn: m[1] === 'global code' ? '<global>' : (m[1] || '<anonymous>'),
        file: m[2],
        line: Number(m[3]),
        col,
        isAsync: false,
      };
    })
    .filter((f): f is NormalizedFrame => f !== null);
}

The isSafari16Plus parameter should be set server-side from the User-Agent header. When unknown, default to false (conservative: subtract 1). A one-column over-correction on Safari 16+ is less harmful than a systematic one-column under-correction on Safari 15.

Step 5 — Unify into a single entry point

export function parseAnyStack(
  stack: string,
  options: { isSafari16Plus?: boolean } = {}
): NormalizedFrame[] {
  const engine = detectEngine(stack);
  switch (engine) {
    case 'v8':           return parseV8Stack(stack);
    case 'spidermonkey': return parseSpiderMonkeyStack(stack);
    case 'jsc':          return parseJSCStack(stack, options.isSafari16Plus ?? false);
    default: {
      // Unknown engine: try all parsers, return the longest result
      const v8  = parseV8Stack(stack);
      const moz = parseSpiderMonkeyStack(stack);
      return v8.length >= moz.length ? v8 : moz;
    }
  }
}

Step 6 — Feed normalized coordinates to SourceMapConsumer

import { SourceMapConsumer } from 'source-map';

export async function symbolicate(
  frames: NormalizedFrame[],
  fetchMapContent: (file: string) => Promise<object>
): Promise<NormalizedFrame[]> {
  const consumers = new Map<string, SourceMapConsumer>();

  const results: NormalizedFrame[] = [];
  for (const frame of frames) {
    let consumer = consumers.get(frame.file);
    if (!consumer) {
      const raw = await fetchMapContent(frame.file);
      consumer = await new SourceMapConsumer(raw as any);
      consumers.set(frame.file, consumer);
    }

    const pos = consumer.originalPositionFor({
      line: frame.line,    // 1-based — unchanged
      column: frame.col,   // 0-based after parseJSCStack normalization
    });

    results.push({
      ...frame,
      fn:   pos.name   ?? frame.fn,
      file: pos.source  ?? frame.file,
      line: pos.line    ?? frame.line,
      col:  pos.column  ?? frame.col,
    });
  }

  for (const c of consumers.values()) c.destroy();
  return results;
}

SourceMapConsumer.originalPositionFor always expects 1-based lines and 0-based columns. After parseAnyStack, both invariants hold regardless of the originating engine.

Production Telemetry Integration

In a server-side symbolication service, the raw stack arrives as a string in an event payload. Pair the engine detector with the ingestion endpoint’s User-Agent header to set isSafari16Plus accurately:

import { IncomingMessage } from 'http';
import UAParser from 'ua-parser-js';  // or any UA parsing library

function isSafari16Plus(req: IncomingMessage): boolean {
  const ua = req.headers['user-agent'] ?? '';
  const parsed = new UAParser(ua);
  const browser = parsed.getBrowser();
  return browser.name === 'Safari' && parseInt(browser.version ?? '0') >= 16;
}

Pass the boolean into parseAnyStack. Cache SourceMapConsumer instances keyed by (file, releaseVersion) to avoid repeated JSON parsing under load. A warm LRU cache of 50 consumers handles most production ingestion rates without significant memory pressure.

If your SDK sends events from a web worker — where navigator.userAgent may be unavailable — attach the UA string to the event payload on the main thread before posting to the worker, or resolve it server-side from the HTTP request header.

Multi-release symbolication. Production systems accumulate source maps from every prior release. Keying the consumer cache on (bundleUrl, releaseVersion) rather than bundleUrl alone prevents cross-release map pollution when the same filename (e.g., app.js) is reused across deployments. Include the release version as a query parameter or a path segment in the map fetch URL: https://artifacts.internal/maps/v1.4.2/app.abc123.js.map. The version must match the release field attached to the error event by your SDK.

Frame filtering before symbolication. Not every frame in a cross-browser stack is worth symbolicating. Frames from CDN-hosted third-party scripts (those whose URL domain does not match your origin) will not have a corresponding source map in your registry. Apply a domain filter before the SourceMapConsumer lookup to avoid unnecessary network requests:

const OWN_ORIGIN = 'https://example.com';

const ownFrames = frames.filter(f => f.file.startsWith(OWN_ORIGIN));
const symbolicated = await symbolicate(ownFrames, fetchMapContent);

// Re-merge: third-party frames are preserved unsymbolicated
const allFrames = frames.map(f =>
  ownFrames.includes(f)
    ? symbolicated[ownFrames.indexOf(f)]
    : f
);

This approach preserves the full stack structure — third-party frames remain visible for context — while avoiding failed map fetches that inflate error rates in your ingestion service’s own monitoring.

Verification & Testing

Build a synthetic fixture that exercises all three engines without requiring real browsers:

// fixture.test.ts
import { describe, it, expect } from 'vitest';
import { parseAnyStack } from './stack-parser';

const V8_STACK = `TypeError: Cannot read properties of null
    at renderComponent (https://example.com/app.abc123.js:1:8432)
    at async bootstrap (https://example.com/app.abc123.js:1:200)`;

const MOZ_STACK = `renderComponent@https://example.com/app.abc123.js:1:8432
async*bootstrap@https://example.com/app.abc123.js:1:200`;

const JSC_STACK = `renderComponent@https://example.com/app.abc123.js:1:8433
global code@https://example.com/app.abc123.js:1:1`;

describe('parseAnyStack', () => {
  it('parses V8 frames with 0-based columns', () => {
    const frames = parseAnyStack(V8_STACK);
    expect(frames[0]).toMatchObject({ fn: 'renderComponent', line: 1, col: 8432 });
  });

  it('parses SpiderMonkey frames and skips async* marker', () => {
    const frames = parseAnyStack(MOZ_STACK);
    expect(frames).toHaveLength(2);
    expect(frames[0]).toMatchObject({ fn: 'renderComponent', col: 8432 });
  });

  it('adjusts JSC 1-based columns to 0-based', () => {
    const frames = parseAnyStack(JSC_STACK, { isSafari16Plus: false });
    expect(frames[0]).toMatchObject({ fn: 'renderComponent', col: 8432 }); // 8433 - 1
    expect(frames[1]).toMatchObject({ fn: '<global>' });
  });

  it('treats JSC columns as 0-based on Safari 16+', () => {
    const frames = parseAnyStack(JSC_STACK, { isSafari16Plus: true });
    expect(frames[0]).toMatchObject({ col: 8433 }); // no adjustment
  });
});

Run with npx vitest run fixture.test.ts and verify all four assertions pass before wiring the parser into your ingestion pipeline.

Failure Modes & Edge Cases

Scenario Root Cause Fix
Safari frames silently drop from symbolicated report JSC column is 1-based; lookup hits wrong VLQ segment; SourceMapConsumer returns null Subtract 1 from JSC columns before lookup; validate with fixture
Firefox async chain missing causal frames async* marker line treated as a malformed frame and discarded Skip lines matching /^async\*$/ explicitly rather than returning null for any non-matching line
V8 anonymous eval frames produce null source Frame references <anonymous>:line:col — no corresponding source map entry Map to a synthetic eval://anonymous sentinel; log but do not block ingestion
SpiderMonkey frames from extension scripts break regex File URL is moz-extension://uuid/..., which the MOZ_FRAME regex captures incorrectly Expand the file-matching segment of MOZ_FRAME to include moz-extension scheme
Mixed-engine event from a PWA service worker Service worker runs a different V8 version than the browser tab Detect engine per-frame rather than per-event; fall back to unknown per-frame
Column reports 0 for every Safari frame Legacy JSC (Safari < 13) omits column information entirely Treat col === 0 post-adjustment as missing; attempt line-only symbolication

FAQ

Why does Chrome symbolicate correctly while the same source map fails for Safari?

Chrome’s V8 reports 0-based columns; SourceMapConsumer expects 0-based columns — they agree without adjustment. Safari’s JavaScriptCore historically reports 1-based columns. Passing a 1-based column to SourceMapConsumer resolves to the position one character after the actual token, which in a minified single-line bundle often resolves to a completely different symbol.

Can I use a single regex to parse all three engines?

No. V8 uses at as a prefix and parentheses to delimit the location. SpiderMonkey and JSC use @ as a delimiter with no parentheses. A regex that handles both requires alternation with enough lookahead that it becomes fragile — a single format change in a browser update breaks the entire parser. Engine-specific regexes with a detection layer are more maintainable and faster to debug.

How do I handle Error.stack in Node.js alongside browser stacks?

Node.js uses V8, so the parseV8Stack function handles it without modification. The only Node-specific consideration is that file paths appear as absolute filesystem paths (/home/user/project/dist/app.js:12:8) rather than HTTP URLs. Your fetchMapContent function must resolve filesystem paths to the corresponding .map files rather than issuing an HTTP fetch.

Is the column-offset problem specific to JavaScriptCore or does it affect other engines?

It is primarily a JavaScriptCore/Safari issue. Both V8 and SpiderMonkey have consistently reported 0-based columns in Error.stack throughout their modern versions. JavaScriptCore aligned to 0-based in Safari 16. If you support Safari 15 or earlier in your error tracking, the 1-based adjustment is mandatory.