Normalizing Chrome and Edge V8 Stack Frames

Chrome and the Chromium-based Edge both run Google’s V8 engine, so their Error.stack output shares one canonical frame format: at functionName (url:line:column). That shared format is convenient until you actually try to parse it, because V8 emits at least four structural variants of that single line and mixing them up sends the wrong coordinates to your symbolicator. This how-to shows you how to turn every V8 frame variant into one normalized object, and it sits under the Cross-Browser Source Map & Stack Compatibility guide within the larger Source Map Generation & Stack Trace Debugging pipeline. If you also ingest Firefox or Safari traces, pair this with Capturing Firefox Async Stack Traces and Parsing Safari/WebKit Stack Trace Format.

Symptom / Trigger

Your Chrome and Edge users report the same crash, yet your dashboard symbolicates only about half of their frames. The unresolved frames all share one trait: they lack the functionName (…) wrapper. A representative raw V8 stack looks like this:

TypeError: Cannot read properties of undefined (reading 'id')
    at renderRow (https://app.example.com/main.4f2c.js:1:88214)
    at Array.forEach (<anonymous>)
    at renderTable (https://app.example.com/main.4f2c.js:1:87003)
    at https://app.example.com/main.4f2c.js:1:12044
    at new UserModel (https://app.example.com/vendor.9ab1.js:1:5521)
    at async loadDashboard (https://app.example.com/main.4f2c.js:1:41190)

A parser that assumes every frame matches at <name> (<url>:<line>:<col>) extracts renderRow, Array.forEach, and renderTable, but silently drops the anonymous frame at line 4 (no parentheses), mishandles the new UserModel constructor frame, and either crashes or keeps the literal async prefix on the last frame. The dropped and mangled frames are exactly the ones you need to see the crash path.

Root Cause Explanation

V8 does not emit one frame shape. The at prefix is constant, but everything after it varies by call-site type. There are four common variants:

  • Named call site: at renderRow (url:line:col) — function name, then location in parentheses.
  • Bare location: at url:line:col — anonymous top-level call sites have no name and no parentheses; the location sits directly after at.
  • Type-qualified name: at Array.forEach (<anonymous>), at new UserModel (url:line:col), at Object.<anonymous> (url:line:col) — the name carries a receiver type, a new constructor marker, or an internal <anonymous> location.
  • Async prefix: at async loadDashboard (url:line:col) — V8 inserts the literal token async before the function name for frames awaited across microtask boundaries.

A regex written for only the first variant fails the other three. The naive version below is the pattern that ships in far too many in-house error trackers.

// Broken pattern — only matches "at name (url:line:col)"
function parseBrokenV8Frame(line) {
  const m = line.match(/^\s*at\s+(.+?)\s+\((.+):(\d+):(\d+)\)$/);
  if (!m) return null;                     // silently drops bare-location frames
  return { fn: m[1], file: m[2], line: +m[3], col: +m[4] };
  // keeps "new " and "async " glued onto fn; never matches "at url:line:col"
}

V8 frame variants normalized into one shapeFour distinct V8 stack frame syntaxes — named, bare location, constructor, and async — all converging on a single NormalizedFrame object with fn, file, line, and column fields.at fn (url:line:col)at url:line:colat new Model (url:line:col)at async fn (url:line:col)NormalizedFrame{ fn, file, line, col }

Because Chrome and Edge share V8, a parser that covers all four variants works identically on both browsers with no per-browser branching. The only real-world divergence is in the User-Agent string, not in the stack format itself. This is the payoff of Chromium consolidation: where you once maintained separate parsers for Trident, EdgeHTML, and Blink, a single V8 code path now symbolicates the overwhelming majority of desktop and Android traffic. That makes correctness on the four variants worth the effort, because a bug in this one parser affects roughly two-thirds of your users at once.

The receiver type in a qualified name — the Array in Array.forEach or the Object in Object.<anonymous> — is metadata V8 attaches for readability, not part of the location. You can keep it as-is inside the fn field for display, since it genuinely helps a human reading the trace understand which prototype method ran. What you must not do is let the new and async prefixes leak into that same field, because those are call-site modifiers, not names, and downstream grouping logic will treat async loadDashboard and loadDashboard as two distinct functions if you do.

Step-by-Step Fix

1. Split the stack and drop the V8 header line

// V8 always starts with "ErrorType: message"; frames begin on the next line
export function splitV8Stack(stack: string): string[] {
  const lines = stack.split('\n');
  const first = lines[0]?.trimStart() ?? '';
  const body = /^\s*at\s/.test(first) ? lines : lines.slice(1); // keep if line 0 is a frame
  return body.map(l => l.trim()).filter(l => l.startsWith('at '));
}

2. Strip the async and new markers off the name field

// Peel V8's leading modifiers so the raw name is clean before location parsing
export function stripFrameMarkers(raw: string): { name: string; isAsync: boolean; isCtor: boolean } {
  let name = raw, isAsync = false, isCtor = false;
  if (name.startsWith('async ')) { isAsync = true; name = name.slice(6); }  // "async "
  if (name.startsWith('new '))   { isCtor = true;  name = name.slice(4); }  // "new "
  return { name: name.trim(), isAsync, isCtor };
}

3. Match both the wrapped and the bare-location frame variants

// Two anchored regexes: one for "name (loc)", one for a bare "loc"
const WRAPPED = /^at\s+(.*?)\s+\((.+):(\d+):(\d+)\)$/;   // at name (url:line:col)
const BARE    = /^at\s+(.+):(\d+):(\d+)$/;               // at url:line:col
export function matchV8Location(line: string) {
  const w = line.match(WRAPPED);
  if (w) return { name: w[1], loc: w[2], line: +w[3], col: +w[4] };
  const b = line.match(BARE);
  if (b) return { name: '', loc: b[1], line: +b[2], col: +b[3] };  // anonymous frame
  return null;
}

4. Assemble each line into a normalized frame

export interface NormalizedFrame {
  fn: string; file: string; line: number; col: number;
  isAsync: boolean; isNative: boolean;
}
export function parseV8Line(line: string): NormalizedFrame | null {
  const hit = matchV8Location(line);
  if (!hit) return null;
  const { name, isAsync } = stripFrameMarkers(hit.name);   // reuse step 2
  const isNative = hit.loc === '<anonymous>' || hit.loc === 'native'; // no source map
  return { fn: name || '<anonymous>', file: hit.loc, line: hit.line, col: hit.col, isAsync, isNative };
}

5. Map the full stack and drop unparseable lines

// Run every frame through parseV8Line; discard nulls so callers get a clean array
export function parseV8Stack(stack: string): NormalizedFrame[] {
  return splitV8Stack(stack)
    .map(parseV8Line)
    .filter((f): f is NormalizedFrame => f !== null);  // type guard removes nulls
}

V8 parsing pipelineA three-stage horizontal pipeline: raw Error.stack string, then split and strip markers, then an array of normalized frame objects ready for symbolication.Raw Error.stackSplit + stripmarkersNormalizedFrame[]

Verification

Run the parser against a fixture that includes all four variants and assert each frame comes out with the markers stripped, the anonymous frame retained, and the columns intact. V8 columns are already 0-based in Error.stack, so unlike Safari there is no offset arithmetic here — you only assert structural correctness.

// verify-v8-parser.test.ts
import { describe, it, expect } from 'vitest';
import { parseV8Stack } from './v8-parser';

const V8_STACK = [
  "TypeError: Cannot read properties of undefined (reading 'id')",
  '    at renderRow (https://app.example.com/main.4f2c.js:1:88214)',
  '    at Array.forEach (<anonymous>)',
  '    at https://app.example.com/main.4f2c.js:1:12044',
  '    at new UserModel (https://app.example.com/vendor.9ab1.js:1:5521)',
  '    at async loadDashboard (https://app.example.com/main.4f2c.js:1:41190)',
].join('\n');

describe('parseV8Stack', () => {
  it('parses all four V8 frame variants', () => {
    const f = parseV8Stack(V8_STACK);
    expect(f).toHaveLength(5);                                // header dropped, 5 frames kept
    expect(f[0]).toMatchObject({ fn: 'renderRow', col: 88214 });
    expect(f[1]).toMatchObject({ fn: 'Array.forEach', isNative: true }); // <anonymous>
    expect(f[2]).toMatchObject({ fn: '<anonymous>', line: 1, col: 12044 }); // bare location
    expect(f[3]).toMatchObject({ fn: 'UserModel' });          // "new " stripped
    expect(f[4]).toMatchObject({ fn: 'loadDashboard', isAsync: true }); // "async " stripped
  });
});

Run npx vitest run verify-v8-parser.test.ts. All assertions passing confirms the five real frames survive, the header is gone, and the constructor and async markers are peeled into boolean flags rather than corrupting the function name. The comparison below makes the practical difference concrete: the naive single-regex approach and the variant-aware approach diverge precisely on the frames that describe how the crash actually propagated, which is exactly where you cannot afford blind spots.

Naive versus variant-aware V8 parsingA before-and-after comparison: the naive single-regex parser drops three frame types, while the variant-aware parser retains every frame and separates markers into flags.Naive regexDrops bare-location framesKeeps new / async in nameHalf the path missingVariant-awareKeeps every frame typeMarkers become flagsFull crash path intact

Edge Cases & Gotchas

  • eval and Function frames — V8 wraps eval-originated frames as at eval (eval at <anonymous> (url:line:col), <anonymous>:1:1). The nested parentheses defeat the WRAPPED regex. Detect the literal eval at substring and extract the outer location before running the standard matchers, or the frame will silently drop.
  • Edge’s legacy Chakra format — Only Chromium Edge (version 79+) emits V8 stacks. The old EdgeHTML/Chakra engine used a different at name (url:line:col) variant without the async marker. If your telemetry still shows Edge 18 traffic, branch on the Edge/ versus Edg/ User-Agent token before assuming V8 semantics.
  • Percent-encoded and query-string URLs — A source URL like main.js?v=4f2c or one containing an encoded : can confuse the greedy (.+) location capture. Anchor the line/column with (\d+):(\d+)$ at the end of the string (as shown) so the URL portion absorbs any interior colons safely.
  • Frame limit truncation — V8 caps Error.stack at Error.stackTraceLimit frames (default 10). Deep async chains lose their oldest frames before your parser ever sees them. Raise the limit at app startup with Error.stackTraceLimit = 50 if you need the full path.

FAQ

Do Chrome and Edge really produce identical stack formats?

Yes, for Chromium-based Edge (version 79 and later) the stack format is byte-for-byte identical to Chrome’s because both delegate to the same V8 build. The only reliable difference is the User-Agent token: Chrome uses Chrome/NN while Chromium Edge appends Edg/NN. You do not need per-browser parsing logic for the stack string itself.

Are V8 columns 0-based or 1-based?

V8 reports 0-based columns in Error.stack, which is exactly what SourceMapConsumer.originalPositionFor expects, so you pass the column through unchanged. This is the opposite of pre-Safari-16 JavaScriptCore, where columns are 1-based and need a decrement — a distinction covered in the Safari parsing how-to.

How should I handle <anonymous> and native frames?

Flag them with isNative and skip the source map lookup entirely, because there is no source file to map back to. Keep them in the array so the reconstructed call path stays continuous for anyone reading the trace, but do not attempt a fetch for their nonexistent .map file.