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 afterat. - 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, anewconstructor marker, or an internal<anonymous>location. - Async prefix:
at async loadDashboard (url:line:col)— V8 inserts the literal tokenasyncbefore 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"
}
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
}
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.
Edge Cases & Gotchas
evalandFunctionframes — V8 wraps eval-originated frames asat eval (eval at <anonymous> (url:line:col), <anonymous>:1:1). The nested parentheses defeat theWRAPPEDregex. Detect the literaleval atsubstring 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 theEdge/versusEdg/User-Agent token before assuming V8 semantics. - Percent-encoded and query-string URLs — A source URL like
main.js?v=4f2cor 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.stackatError.stackTraceLimitframes (default 10). Deep async chains lose their oldest frames before your parser ever sees them. Raise the limit at app startup withError.stackTraceLimit = 50if 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.
Related
- Cross-Browser Source Map & Stack Compatibility — the parent guide that unifies all three engine parsers.
- Source Map Generation & Stack Trace Debugging — the full build, upload, and symbolication pipeline.
- Parsing Safari/WebKit Stack Trace Format — the JavaScriptCore counterpart with its column-offset quirk.
- Capturing Firefox Async Stack Traces — how SpiderMonkey marks async boundaries differently from V8.