Normalizing V8 and SpiderMonkey Stack Frames

Chrome and Firefox produce fundamentally incompatible stack-trace strings — a regex that perfectly extracts file and line data from one engine will silently return null on the other, swallowing every frame your error-monitoring pipeline tries to symbolicate. This page walks through a production-grade parser that unifies both formats under a single StackFrame interface, and fits into the broader cross-browser stack trace normalization techniques covered in the Source Map Generation & Stack Trace Debugging guide.

V8 and SpiderMonkey parsing pipeline Two rows: the top row shows a V8 raw stack line feeding into the V8 regex parser, then outputting a unified StackFrame box. The bottom row shows a SpiderMonkey raw stack line feeding into the SM regex parser, then the same unified StackFrame box. Arrows connect each stage. V8 (Chrome) SpiderMonkey (Firefox) at fetchUser (app.js:42:8) at async run (app.js:10:3) raw V8 stack string V8 Parser /at (?:async )?(.+?) \((.+):(\d+):(\d+)\)/ named capture groups StackFrame functionName: "fetchUser" fileName: "app.js" lineNumber: 42 columnNumber: 8 isAsync: false functionName: "fetchUser" fileName: "app.js" lineNumber: 42 columnNumber: 8 isAsync: false [email protected]:42:8 @app.js:10:3 raw SM stack string SM Parser /^(.*)@(.+):(\d+):(\d+)$/ greedy fn capture

Symptom / Trigger

Your error ingestion pipeline processes stacks from real user sessions and returns empty frames or throws on Firefox reports. The two formats look nothing alike:

// Chrome / Node.js (V8 engine) — "at" prefix with parenthesised location
const v8Stack = `Error: Network timeout
    at fetchUser (https://app.example.com/static/bundle.js:42:8)
    at async run (https://app.example.com/static/bundle.js:10:3)
    at Array.forEach (<anonymous>)
    at Object.bootstrap (https://app.example.com/static/bundle.js:5:1)`;

// Firefox (SpiderMonkey engine) — "@" separator, no parentheses, no "at"
const smStack = `fetchUser@https://app.example.com/static/bundle.js:42:8
run@https://app.example.com/static/bundle.js:10:3
@https://app.example.com/static/bundle.js:5:1`;

// A regex written for V8:
const V8_ONLY = /^\s+at\s+(?:(.+?)\s+\()?(.+):(\d+):(\d+)\)?/;

console.log(V8_ONLY.test(smStack.split('\n')[0])); // false — SM frames silently ignored

Every Firefox session produces zero symbolicated frames, causing a blind spot in your error monitoring.

Root Cause Explanation

V8 and SpiderMonkey were designed independently and never converged on a stack format. The structural differences are deep enough that no single regex can cover both without ambiguity:

// V8 structural rules:
// 1. Each frame starts with "    at " (four spaces + "at ")
// 2. Named frames: "at FunctionName (file:line:col)"
// 3. Anonymous frames: "at file:line:col" — no parentheses, no name
// 4. Async frames: "at async FunctionName (file:line:col)"
// 5. Native frames: "at Array.forEach (<anonymous>)" or "at Array.map (native)"
// 6. Method frames include object qualifier: "at Object.bootstrap (...)"

// SpiderMonkey structural rules:
// 1. No leading "at" — the line starts immediately with the function name or "@"
// 2. Named frames: "functionName@file:line:col"
// 3. Anonymous frames: "@file:line:col" — just the "@" with no name before it
// 4. No async keyword prefix — async frames look identical to sync frames
// 5. No native sentinel — native frames simply omit the file/line section

// The fatal conflict:
// V8 anonymous frame:  "    at https://cdn.example.com/app.js:5:1"
// SM named frame:      "bootstrap@https://cdn.example.com/app.js:5:1"
// A single regex that allows an optional name will misparse one or the other.

Attempting to smash both formats into one pattern produces false positives: the colon separators inside https:// URLs get misidentified as line/column delimiters, and optional-group regexes silently match the wrong capture group when the name is absent.

Step-by-Step Fix

Step 1 — Detect the engine from the raw stack string

/**
 * Returns 'v8' when the stack contains V8-style "    at " frames,
 * 'spidermonkey' when it contains the "@" separator pattern,
 * or 'unknown' when neither is detected.
 */
function detectEngine(rawStack) {
  // V8 always emits "    at " (four spaces) in frame lines
  if (/^\s{4}at /m.test(rawStack)) return 'v8';

  // SpiderMonkey frames always contain "@" before a URL with a colon
  // We require something that looks like "@<scheme>://" or "@/" to avoid
  // false matches on email-like strings in the error message itself.
  if (/@(?:https?:\/\/|blob:|webpack:\/\/|\/)/m.test(rawStack)) return 'spidermonkey';

  return 'unknown';
}

Step 2 — Build the V8 parser

// Named capture groups make extraction explicit and self-documenting.
// The regex must handle four distinct V8 frame shapes:
//   (a) "    at FunctionName (file:line:col)"
//   (b) "    at async FunctionName (file:line:col)"
//   (c) "    at file:line:col"              ← anonymous, no parens
//   (d) "    at FunctionName (<anonymous>)" ← V8 internal anonymous

const V8_FRAME_RE = /^\s+at\s+(?:(?<isAsync>async)\s+)?(?:(?<fn>[^(]+?)\s+\()?(?<file>[^)]+):(?<line>\d+):(?<col>\d+)\)?/;

// Matches shape (c) — anonymous with no parens wrapping the location
const V8_ANON_RE = /^\s+at\s+(?<file>.+):(?<line>\d+):(?<col>\d+)\s*$/;

// Matches native sentinel frames — return null for these
const V8_NATIVE_RE = /^\s+at\s+.+\s+\((?:native|<anonymous>)\)\s*$/;

function parseV8Frame(line) {
  if (V8_NATIVE_RE.test(line)) {
    // Native frames have no file/line/col — return a sentinel
    const nativeFn = line.match(/at\s+(?:async\s+)?(.+?)\s+\(/)?.[1] ?? '<native>';
    return { functionName: nativeFn, fileName: null, lineNumber: null, columnNumber: null, isAsync: false };
  }

  const m = line.match(V8_FRAME_RE);
  if (m?.groups) {
    const { fn, file, line: ln, col, isAsync } = m.groups;
    return {
      functionName: fn?.trim() ?? '<anonymous>',
      fileName: file.trim(),
      lineNumber: parseInt(ln, 10),
      columnNumber: parseInt(col, 10),
      isAsync: Boolean(isAsync),
    };
  }

  // Fall back to anonymous shape (c)
  const ma = line.match(V8_ANON_RE);
  if (ma?.groups) {
    return {
      functionName: '<anonymous>',
      fileName: ma.groups.file.trim(),
      lineNumber: parseInt(ma.groups.line, 10),
      columnNumber: parseInt(ma.groups.col, 10),
      isAsync: false,
    };
  }

  return null; // unparseable line
}

Step 3 — Build the SpiderMonkey parser

// SpiderMonkey format: "functionName@file:line:col"
// The function name can be empty (anonymous) or contain dots and slashes
// (e.g. "App.prototype.render").
// The tricky part: the file URL itself contains colons ("https://...")
// so we cannot split naively on the last colon. Instead we anchor on the
// LAST two colon-separated numeric segments at end of line.

const SM_FRAME_RE = /^(?<fn>[^@]*)@(?<file>.+):(?<line>\d+):(?<col>\d+)$/;

function parseSpiderMonkeyFrame(line) {
  const trimmed = line.trim();
  if (!trimmed) return null;

  const m = trimmed.match(SM_FRAME_RE);
  if (!m?.groups) return null;

  const { fn, file, line: ln, col } = m.groups;

  // SpiderMonkey omits the file entirely for truly native frames
  // In that case file will be something non-URL like "self-hosted"
  const isNative = !file.includes('/') && !file.startsWith('http');

  return {
    functionName: fn.trim() || '<anonymous>',
    fileName: isNative ? null : file.trim(),
    lineNumber: isNative ? null : parseInt(ln, 10),
    columnNumber: isNative ? null : parseInt(col, 10),
    isAsync: false, // SM provides no async marker in the stack string
  };
}

Step 4 — Normalize both into the shared StackFrame interface

export interface StackFrame {
  functionName: string;
  fileName: string | null;
  lineNumber: number | null;
  columnNumber: number | null;
  isAsync: boolean;
}

/**
 * Parses any raw Error.stack string into an array of normalized StackFrame objects.
 * The first line (the error message itself) is automatically skipped.
 */
export function parseStack(rawStack: string): StackFrame[] {
  const engine = detectEngine(rawStack);

  return rawStack
    .split('\n')
    .slice(1) // drop "Error: <message>" header line present in V8 stacks
    .filter(Boolean)
    .map((line): StackFrame | null => {
      if (engine === 'v8') return parseV8Frame(line);
      if (engine === 'spidermonkey') return parseSpiderMonkeyFrame(line);

      // Unknown engine: attempt both parsers, prefer whichever matches
      return parseV8Frame(line) ?? parseSpiderMonkeyFrame(line) ?? null;
    })
    .filter((f): f is StackFrame => f !== null);
}

Step 5 — Handle webpack:// and blob: URL edge cases

/**
 * Strips the webpack:// scheme prefix and resolves the real relative path
 * so that source-map consumers can look up the correct source entry.
 *
 * webpack:///./src/utils/api.ts  →  ./src/utils/api.ts
 * blob:https://example.com/abc  →  kept as-is (blob URLs have no source map)
 */
export function normalizeFileName(rawFileName) {
  if (!rawFileName) return null;

  // webpack dev/prod source maps use "webpack:///<relative-path>"
  const webpackMatch = rawFileName.match(/^webpack:\/\/\/(.+)$/);
  if (webpackMatch) return webpackMatch[1];

  // blob: URLs cannot be symbolicated; flag them rather than dropping them
  if (rawFileName.startsWith('blob:')) return rawFileName;

  return rawFileName;
}

// Plug into the parser output:
export function parseStackWithNormalization(rawStack) {
  return parseStack(rawStack).map(frame => ({
    ...frame,
    fileName: normalizeFileName(frame.fileName),
  }));
}

Verification

// Paste this into a Node.js REPL or a Vitest / Jest test suite.
// It creates synthetic stack strings for both engines and asserts
// the normalized output is identical.

import assert from 'node:assert/strict';
import { parseStack } from './stackParser.js';

const V8_RAW = `Error: timeout
    at fetchUser (https://app.example.com/bundle.js:42:8)
    at async run (https://app.example.com/bundle.js:10:3)
    at Array.forEach (<anonymous>)`;

const SM_RAW = `fetchUser@https://app.example.com/bundle.js:42:8
run@https://app.example.com/bundle.js:10:3
@https://app.example.com/bundle.js:5:1`;

const v8Frames = parseStack(V8_RAW);
const smFrames = parseStack(SM_RAW);

// Both engines should produce the same first frame
assert.equal(v8Frames[0].functionName, 'fetchUser', 'V8: functionName');
assert.equal(v8Frames[0].fileName,     'https://app.example.com/bundle.js', 'V8: fileName');
assert.equal(v8Frames[0].lineNumber,   42, 'V8: lineNumber');
assert.equal(v8Frames[0].columnNumber, 8,  'V8: columnNumber');
assert.equal(v8Frames[0].isAsync,      false, 'V8: isAsync');

assert.equal(smFrames[0].functionName, 'fetchUser', 'SM: functionName');
assert.equal(smFrames[0].fileName,     'https://app.example.com/bundle.js', 'SM: fileName');
assert.equal(smFrames[0].lineNumber,   42, 'SM: lineNumber');
assert.equal(smFrames[0].columnNumber, 8,  'SM: columnNumber');
assert.equal(smFrames[0].isAsync,      false, 'SM: isAsync');

// V8 async frame
assert.equal(v8Frames[1].functionName, 'run',  'V8: async functionName');
assert.equal(v8Frames[1].isAsync,      true,   'V8: isAsync flag set');

// V8 native frame — fileName should be null
assert.equal(v8Frames[2].fileName, null, 'V8: native frame has no fileName');

console.log('All assertions passed.');

Edge Cases & Gotchas

  • Eval frames carry a nested location. V8 eval frames look like at eval (eval at callSite (app.js:10:5), <anonymous>:1:1). The outer eval at clause contains a second file reference. Your parser must either strip the outer clause before applying the frame regex or handle it as a two-segment location. Skipping eval frames entirely is safest for symbolication because they usually represent dynamically generated code with no corresponding source map entry.

  • TypeScript method decorators produce mangled names in V8. A decorator applied to UserService.prototype.fetchUser can appear as UserService_1.fetchUser or __decorate.<anonymous> depending on the tsconfig target. Do not rely on functionName alone to match a source-map name; always prefer file/line/column as the primary lookup key and treat functionName as a display hint.

  • Firefox omits column numbers for older self-hosted code. SpiderMonkey frames for built-in JS operations like Array.prototype.map sometimes appear as map@self-hosted:1 with no column. The SM parser above returns null for columnNumber in these cases. Downstream symbolication callers must handle a null column gracefully rather than passing it directly to a source-map consumer that expects a number.

  • Resource URLs containing query strings break naive last-colon splitting. A frame referencing bundle.js?v=3.2.1:100:5 has colons in the version string. Because SM_FRAME_RE anchors to \d+:\d+$ at end-of-line, it correctly identifies only the trailing numeric pair as line/column. If you were using a non-anchored split approach you would misparse the query parameter as part of the line number.

FAQ

Why does Error.stack have no official format in the JavaScript specification?

The Error.prototype.stack property was invented by V8, copied informally by other engines, and was never included in the ECMAScript standard. TC39 has an ongoing proposal — the Error Stacks proposal — but as of 2026 it remains at Stage 1. Each engine therefore chose its own format independently, and the divergence you see between V8 and SpiderMonkey reflects a decade of parallel evolution with no interoperability requirement.

Can I use an existing library instead of writing my own parser?

Yes. The error-stack-parser package handles V8, SpiderMonkey, and JavaScriptCore with a battle-tested regex suite. For most production use cases it is the right choice. Rolling your own is worthwhile when you need to preserve the isAsync flag from V8 frames, handle webpack:// URLs without an extra post-processing step, or avoid adding a dependency to a size-sensitive browser bundle. The implementation on this page is intentionally minimal so that the parsing logic is easy to audit and extend for local symbolication with the Mozilla source-map library.

Does this parser work in Node.js as well as browsers?

Yes. Node.js uses the V8 engine, so all V8 parsing logic applies unchanged. The only difference is that Node.js stack frames reference absolute file-system paths (/home/user/app/src/index.js) rather than HTTP URLs, and they may include node:internal/ protocol frames for core module calls. The detectEngine function correctly identifies these as V8 stacks because they still use the at prefix. You will want to add a filter step to drop frames whose fileName starts with node: when you do not want core-module noise in your symbolicated output.