Parsing Safari/WebKit Stack Trace Format
Safari’s JavaScriptCore engine emits Error.stack in a format that is superficially similar to Firefox’s SpiderMonkey but contains three divergences that systematically break parsers written against V8 or SpiderMonkey output. Handling them requires engine-specific logic inside your stack parser. This page covers the exact quirks, shows a production-ready parser, and integrates with the column-offset normalization described in Cross-Browser Source Map and Stack Compatibility and the broader Source Map Generation & Stack Trace Debugging pipeline.
Symptom / Trigger
Safari users appear with unresolved minified frames in your error dashboard while the identical error symbolicates correctly for Chrome and Firefox users. Inspecting the raw event shows a stack string like this:
renderComponent@https://example.com/app.abc123.js:1:9433
handleClick@https://example.com/app.abc123.js:1:3201
global code@https://example.com/app.abc123.js:1:1
Notice what is missing: there is no TypeError: Cannot read properties of null header line before the first frame. The global code entry at the bottom has no V8 or SpiderMonkey equivalent. And the column numbers are 1-based, not 0-based.
Feeding these frames to a V8-style parser yields zero matches because no line starts with at . Feeding them to a SpiderMonkey parser extracts the frames but passes column 9433 directly to SourceMapConsumer.originalPositionFor, which expects column 9432. In a minified single-line bundle this resolves to the wrong token — symbolication returns an incorrect function name with no indication that anything went wrong.
Root Cause Explanation
JavaScriptCore has three independent deviations from V8 and SpiderMonkey conventions.
No Error: header line. When a TypeError or RangeError is thrown in global scope, JavaScriptCore omits the first Error: message line that V8 always includes. Parsers that call .slice(1) to skip the V8 header on a JSC stack discard the first real frame.
global code sentinel. Frames that execute at the top level of a module or script (outside any function) receive the synthetic function name global code in JSC. This is not a reserved keyword or a special construct — it is just a string that JSC injects. V8 would produce <anonymous> for these frames; SpiderMonkey produces an empty function name field.
1-based columns in Safari ≤ 15. SourceMapConsumer.originalPositionFor uses the same convention as Error.stack in V8 and SpiderMonkey: lines are 1-based, columns are 0-based. JSC violated this convention through Safari 15, reporting columns starting from 1. Safari 16 aligned with the 0-based convention, but because you cannot determine the Safari major version from the stack string alone, you must derive it from the User-Agent header.
// Broken pattern — passes raw JSC column without offset correction
function parseBrokenJSCFrame(line) {
const m = line.match(/^(.*?)@(.*):(\d+):(\d+)$/);
if (!m) return null;
return { fn: m[1], file: m[2], line: +m[3], col: +m[4] }; // col is 1-based on Safari ≤ 15
}
// consumer.originalPositionFor({ line: 1, column: 9433 }) → wrong symbol
Step-by-Step Fix
1. Detect a JSC stack string
// Returns true if the stack was produced by JavaScriptCore (Safari / WebKit)
export function isJSCStack(stack: string): boolean {
if (!stack) return false;
// JSC uses @ delimiter and never starts with "Error:" in its canonical form
// SpiderMonkey also uses @ but inserts "async*" markers or an "Error:" header
const hasAtDelimiter = stack.includes('@');
const hasV8AtPrefix = /\n\s+at /.test(stack);
const hasAsyncMarker = stack.includes('async*');
return hasAtDelimiter && !hasV8AtPrefix && !hasAsyncMarker;
}
This is conservative: it excludes SpiderMonkey by requiring the absence of async* markers and at –prefixed lines. Apply this check inside a broader engine-detection function rather than calling it on every frame individually.
2. Determine the column offset from User-Agent
On a server-side ingestion endpoint you have access to the HTTP User-Agent header of the originating request. Parse it to derive the Safari major version:
// Minimal Safari major-version extractor — no external library needed
export function safariMajorVersion(userAgent: string): number | null {
// Safari UA: "... Version/16.5 Safari/605.1.15"
const m = userAgent.match(/Version\/(\d+)\.\d+.*Safari\//);
return m ? parseInt(m[1], 10) : null;
}
// Returns true when JSC columns are already 0-based (Safari 16+)
export function jsColumnIsZeroBased(userAgent: string): boolean {
const major = safariMajorVersion(userAgent);
return major !== null && major >= 16;
}
If you cannot access the UA (e.g., the stack was captured in a service worker and the UA was not forwarded), default to false — treat columns as 1-based. A systematic one-column over-correction on Safari 16+ (shifting already-0-based columns to -1, clamped to 0) is less harmful than a systematic under-correction on Safari 15 (passing 1-based columns unchanged).
3. Parse each JSC frame
const JSC_FRAME_RE = /^(.*?)@(https?:\/\/.+):(\d+):(\d+)$/;
export interface NormalizedFrame {
fn: string;
file: string;
line: number; // 1-based (unchanged from Error.stack)
col: number; // 0-based (normalized in this function)
isGlobal: boolean;
}
export function parseJSCStack(
stack: string,
zeroBased: boolean // true for Safari 16+
): NormalizedFrame[] {
return stack
.split('\n')
.map((raw): NormalizedFrame | null => {
const trimmed = raw.trim();
if (!trimmed) return null;
// Skip any "ErrorType: message" line that some JSC versions include
if (/^\w+Error:/.test(trimmed) && !trimmed.includes('@')) return null;
const m = trimmed.match(JSC_FRAME_RE);
if (!m) return null;
const rawCol = parseInt(m[4], 10);
const col = zeroBased ? rawCol : Math.max(0, rawCol - 1); // normalize
return {
fn: m[1] === 'global code' ? '<global>' : (m[1] || '<anonymous>'),
file: m[2],
line: parseInt(m[3], 10),
col,
isGlobal: m[1] === 'global code',
};
})
.filter((f): f is NormalizedFrame => f !== null);
}
The isGlobal flag lets downstream logic suppress <global> frames from user-facing error reports while still preserving them for full-fidelity debugging.
4. Handle the missing header edge case
When the error is thrown in a try/catch block rather than at the top level, some JSC versions include the Error: message header. The header-skip condition in step 3 handles this, but you should also guard against the inverse: a frame whose function name contains a colon (e.g., a URL accidentally matched as a function name).
// Guard: reject frames where the "file" field doesn't look like a URL
function looksLikeUrl(s: string): boolean {
return /^https?:\/\//.test(s);
}
// After parsing, filter out malformed entries
const frames = parseJSCStack(rawStack, zeroBased)
.filter(f => looksLikeUrl(f.file));
This eliminates mismatched captures when an error message contains @ and ends up being parsed as a frame line.
5. Feed normalized frames to SourceMapConsumer
import { SourceMapConsumer } from 'source-map';
export async function symbolicateJSCFrames(
frames: NormalizedFrame[],
getMap: (fileUrl: string) => Promise<object>
): Promise<NormalizedFrame[]> {
const cache = new Map<string, SourceMapConsumer>();
const out: NormalizedFrame[] = [];
for (const frame of frames) {
const mapUrl = frame.file + '.map';
let consumer = cache.get(mapUrl);
if (!consumer) {
consumer = await new SourceMapConsumer(await getMap(mapUrl) as any);
cache.set(mapUrl, consumer);
}
const orig = consumer.originalPositionFor({
line: frame.line, // 1-based — correct as-is
column: frame.col, // 0-based after normalization above
});
out.push({
...frame,
fn: orig.name ?? frame.fn,
file: orig.source ?? frame.file,
line: orig.line ?? frame.line,
col: orig.column ?? frame.col,
});
}
for (const c of cache.values()) c.destroy();
return out;
}
Verification
Trigger a controlled throw in Safari, capture the raw Error.stack, and assert that your parser produces the expected 0-based column:
// verify-jsc-parser.test.ts
import { describe, it, expect } from 'vitest';
import { parseJSCStack } from './jsc-parser';
// Fixture from Safari 15 (1-based columns)
const SAFARI_15_STACK = [
'renderApp@https://example.com/app.abc123.js:1:9433',
'bootstrap@https://example.com/app.abc123.js:1:451',
'global code@https://example.com/app.abc123.js:1:1',
].join('\n');
describe('parseJSCStack', () => {
it('converts Safari 15 columns from 1-based to 0-based', () => {
const frames = parseJSCStack(SAFARI_15_STACK, false /* Safari 15 */);
expect(frames[0]).toMatchObject({ fn: 'renderApp', col: 9432 }); // 9433 → 9432
expect(frames[1]).toMatchObject({ fn: 'bootstrap', col: 450 }); // 451 → 450
expect(frames[2]).toMatchObject({ fn: '<global>', col: 0 }); // 1 → 0
expect(frames[2].isGlobal).toBe(true);
});
it('leaves Safari 16 columns unchanged', () => {
const frames = parseJSCStack(SAFARI_15_STACK, true /* Safari 16+ */);
expect(frames[0].col).toBe(9433); // no adjustment
});
it('skips Error: header line when present', () => {
const withHeader = 'TypeError: null is not an object\n' + SAFARI_15_STACK;
const frames = parseJSCStack(withHeader, false);
expect(frames[0].fn).toBe('renderApp'); // header not treated as a frame
});
});
Run npx vitest run verify-jsc-parser.test.ts. All three cases passing confirms that column normalization, <global> mapping, and header-skipping behave correctly before you wire this into production ingestion.
Edge Cases & Gotchas
- Safari extensions and JavaScriptCore-based WebViews — WKWebView and iOS Safari both use JSC, but iOS Safari before iOS 16 behaves identically to desktop Safari 15 (1-based columns). WKWebView UA strings do not always include
Version/NN, sosafariMajorVersion()may returnnull. Default to 1-based in that case. @in function names — Though rare, minifiers can produce property-accessor expressions that contain@in their stringified form (template literal tags, decorator metadata). TheJSC_FRAME_REanchors the URL portion withhttps?://, which prevents the right-most@from being misidentified as the frame delimiter in the vast majority of cases. If you use protocol-relative URLs, expand the regex to cover//prefixes.- Inline scripts and
<anonymous>URLs — JSC may producefn@[native code]:0:0for built-ins orfn@undefined:0:0when a source URL is not attached to a script tag. These frames cannot be symbolicated; log them asnativeand skip theSourceMapConsumerlookup to avoid a fetch error. - Multi-frame same-line collisions — In minified bundles, multiple JSC frames can share
line: 1. The column is the only discriminator. A one-column error propagates to a different symbol. This is why column normalization is not optional — it is the primary differentiator between correct and incorrect symbolication on single-line bundles.
FAQ
Why doesn’t Safari 16 need column adjustment?
Apple aligned JavaScriptCore with the ECMAScript convention (0-based columns in Error.stack) starting in Safari 16. Versions before 16 used 1-based columns in Error.stack while SourceMapConsumer expected 0-based — a gap that produced systematically off-by-one symbolication.
My error tracker already parses stacks. Do I still need this?
Only if your tracker handles JSC column offsets correctly. Many open-source parsers (including older versions of the error-stack-parser npm package) do not subtract 1 from JSC columns. Check your tracker’s source or test it with a Safari 15 UA and verify the resolved function name matches the actual throw site in your source.
How do I test this without a real Safari device?
Construct synthetic stack strings that match the JSC format (shown in the fixture above) and run them through your parser in any Node.js environment. The column-offset logic is pure arithmetic — it does not require a browser. For full end-to-end symbolication testing, use a real .map file from a known build and assert that originalPositionFor returns the expected source location.