Capturing Firefox Async Stack Traces

Firefox’s SpiderMonkey engine extends Error.stack with an async* marker that separates synchronous frames from the causal async frames that scheduled them. When a parser discards this marker — or stops processing at it — the causal portion of the chain is lost, leaving observers with an incomplete picture of what initiated the failure. This guide shows how SpiderMonkey constructs async stacks, how to parse the async* separator correctly, and how to reassemble the full causal chain for symbolication. It builds on the engine-comparison foundation in Cross-Browser Source Map and Stack Compatibility and the broader Source Map Generation & Stack Trace Debugging pipeline.

Firefox Async Stack Trace Structure and Parser Flow A Firefox Error.stack string showing synchronous frames above an async* separator, followed by the causal async frames below it. A parser splits on the async* marker, labels each segment as synchronous or causal, and produces a merged frame array for SourceMapConsumer symbolication. Raw Firefox Error.stack synchronous [email protected]:1:820 [email protected]:1:3100 async* causal [email protected]:1:5900 [email protected]:1:200 async* [email protected]:1:50 split on async* AsyncFrame[] { fn: 'handleError', type: 'sync' } { fn: 'processData', type: 'sync' } ── async boundary ── { fn: 'fetchUser', type: 'causal' } { fn: 'initApp', type: 'causal' } Source Map lookup All frames — sync and causal — feed into symbolication

Symptom / Trigger

An unhandled promise rejection in Firefox produces a stack like this in your error tracker’s raw event payload:

TypeError: NetworkError when attempting to fetch resource.
handleError@https://example.com/app.abc123.js:1:820
processData@https://example.com/app.abc123.js:1:3100
async*
fetchUser@https://example.com/app.abc123.js:1:5900
initApp@https://example.com/app.abc123.js:1:200
async*
main@https://example.com/app.abc123.js:1:50

If your parser splits on \n and applies a single fn@url:line:col regex to every line, the async* lines return null from the match, get filtered out, and the frames below each async* are discarded when the filter stops the array early. The resulting symbolicated stack contains only handleError and processData. The causal path — fetchUserinitAppmain — vanishes. You know the error site but not what caused the execution to reach it.

Root Cause Explanation

SpiderMonkey implements a concept called async causal stacks: when an await expression suspends execution, the engine records the call site that scheduled the await and prepends it below an async* separator when the promise rejects. Multiple await boundaries produce multiple async* separators, creating a chain of causal segments.

The async* line is not a frame. It is a segment delimiter. Parsers that treat every non-empty line as a potential frame and filter out non-matching lines happen to work correctly for synchronous stacks — but they silently truncate async stacks at the first async* marker.

// Broken pattern — treats async* as a malformed frame, drops everything after it
function parseBrokenMozStack(stack) {
  return stack
    .split('\n')
    .slice(1)                    // skip "Error: message"
    .map(line => {
      const m = line.match(/^(.*?)@(.*):(\d+):(\d+)$/);
      return m ? { fn: m[1], file: m[2], line: +m[3], col: +m[4] } : null;
    })
    .filter(Boolean);            // null entries (async*) get silently dropped
  // Result: only frames above the first async* boundary survive
}

The symptom is invisible: filter(Boolean) produces a shorter array with no error or warning. The causal frames are gone.

Step-by-Step Fix

1. Split on async* boundaries before frame parsing

Treat async* as a segment separator, not a line to parse:

export type FrameType = 'sync' | 'causal';

export interface AsyncFrame {
  fn:      string;
  file:    string;
  line:    number;
  col:     number;
  type:    FrameType;
  depth:   number;    // async boundary depth (0 = innermost/synchronous)
}

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

export function parseFirefoxAsyncStack(stack: string): AsyncFrame[] {
  const lines = stack.split('\n');

  // Skip the "ErrorType: message" header if present
  const firstFrame = lines.findIndex(l => l.includes('@'));
  const frameLines  = firstFrame >= 0 ? lines.slice(firstFrame) : lines;

  const frames: AsyncFrame[] = [];
  let depth = 0;      // increments at each async* boundary

  for (const raw of frameLines) {
    const trimmed = raw.trim();

    if (trimmed === 'async*') {
      depth++;        // cross an async boundary — subsequent frames are causal
      continue;       // do NOT return null; do NOT break
    }

    if (!trimmed) continue;

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

    frames.push({
      fn:    m[1] || '<anonymous>',
      file:  m[2],
      line:  parseInt(m[3], 10),
      col:   parseInt(m[4], 10),  // SpiderMonkey: 0-based — no adjustment needed
      type:  depth === 0 ? 'sync' : 'causal',
      depth,
    });
  }

  return frames;
}

The key change is continue instead of returning null on the async* line. depth increments monotonically, so every frame below the first async* receives type: 'causal' and depth: 1, frames below the second async* receive depth: 2, and so on.

2. Reconstruct the causal chain for display

For error dashboards, causal frames are typically shown as a separate section below a visual separator. This function produces a display-ready structure:

export interface CausalChain {
  segments: Array<{
    depth:  number;
    label:  string;
    frames: AsyncFrame[];
  }>;
}

export function buildCausalChain(frames: AsyncFrame[]): CausalChain {
  const byDepth = new Map<number, AsyncFrame[]>();
  for (const f of frames) {
    if (!byDepth.has(f.depth)) byDepth.set(f.depth, []);
    byDepth.get(f.depth)!.push(f);
  }

  const segments = Array.from(byDepth.entries())
    .sort(([a], [b]) => a - b)
    .map(([depth, depthFrames]) => ({
      depth,
      label: depth === 0 ? 'Synchronous call stack' : `Async caller (depth ${depth})`,
      frames: depthFrames,
    }));

  return { segments };
}

Pass the CausalChain to your UI layer. Render depth: 0 frames as the primary stack and depth >= 1 frames as a collapsible “caused by” section, matching the UX pattern used by Sentry and Chrome DevTools.

3. Symbolicate all frames uniformly

Causal frames require symbolication exactly like synchronous frames — their coordinates are in the same minified bundle:

import { SourceMapConsumer } from 'source-map';

export async function symbolicateFirefoxStack(
  frames: AsyncFrame[],
  fetchMap: (fileUrl: string) => Promise<object>
): Promise<AsyncFrame[]> {
  const consumers = new Map<string, SourceMapConsumer>();

  const out: AsyncFrame[] = [];
  for (const frame of frames) {
    const mapUrl = frame.file + '.map';
    let consumer = consumers.get(mapUrl);
    if (!consumer) {
      consumer = await new SourceMapConsumer(await fetchMap(mapUrl) as any);
      consumers.set(mapUrl, consumer);
    }

    const orig = consumer.originalPositionFor({
      line:   frame.line,   // 1-based — correct as-is
      column: frame.col,    // 0-based — correct for SpiderMonkey
    });

    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 consumers.values()) c.destroy();
  return out;
}

Because SpiderMonkey always reports 0-based columns, no offset adjustment is needed before passing to SourceMapConsumer. This is in direct contrast to Safari’s JavaScriptCore, which requires subtracting 1 from columns on versions prior to Safari 16.

4. Handle nested async boundaries

Deeply nested async call chains produce multiple async* separators. Each separator represents one await boundary in the original code. A function that calls await fetchUser() which itself calls await readCache() produces:

[email protected]:1:820        ← depth 0 (sync)
async*
[email protected]:1:5900         ← depth 1 (first await boundary)
async*
[email protected]:1:12300        ← depth 2 (second await boundary)

The depth counter in step 1 handles this automatically because it increments at every async* line. Validate nested chains in your test fixture:

const NESTED_ASYNC_STACK = [
  'TypeError: failed to fetch',
  'handleError@https://example.com/app.abc123.js:1:820',
  'async*',
  'fetchUser@https://example.com/app.abc123.js:1:5900',
  'async*',
  'readCache@https://example.com/app.abc123.js:1:12300',
].join('\n');

const frames = parseFirefoxAsyncStack(NESTED_ASYNC_STACK);
// frames[0]: { fn: 'handleError', depth: 0, type: 'sync' }
// frames[1]: { fn: 'fetchUser',   depth: 1, type: 'causal' }
// frames[2]: { fn: 'readCache',   depth: 2, type: 'causal' }

Verification

// firefox-async-parser.test.ts
import { describe, it, expect } from 'vitest';
import { parseFirefoxAsyncStack, buildCausalChain } from './firefox-async-parser';

const STACK = [
  'TypeError: NetworkError when attempting to fetch resource.',
  'handleError@https://example.com/app.abc123.js:1:820',
  'processData@https://example.com/app.abc123.js:1:3100',
  'async*',
  'fetchUser@https://example.com/app.abc123.js:1:5900',
  'initApp@https://example.com/app.abc123.js:1:200',
  'async*',
  'main@https://example.com/app.abc123.js:1:50',
].join('\n');

describe('parseFirefoxAsyncStack', () => {
  it('parses all frames including those after async* markers', () => {
    const frames = parseFirefoxAsyncStack(STACK);
    expect(frames).toHaveLength(5);  // not 2 — causal frames are kept
  });

  it('tags sync and causal frames correctly', () => {
    const frames = parseFirefoxAsyncStack(STACK);
    expect(frames[0]).toMatchObject({ fn: 'handleError', type: 'sync',   depth: 0 });
    expect(frames[1]).toMatchObject({ fn: 'processData', type: 'sync',   depth: 0 });
    expect(frames[2]).toMatchObject({ fn: 'fetchUser',   type: 'causal', depth: 1 });
    expect(frames[3]).toMatchObject({ fn: 'initApp',     type: 'causal', depth: 1 });
    expect(frames[4]).toMatchObject({ fn: 'main',        type: 'causal', depth: 2 });
  });

  it('builds a causal chain with correct segment labels', () => {
    const frames = parseFirefoxAsyncStack(STACK);
    const chain  = buildCausalChain(frames);
    expect(chain.segments).toHaveLength(3);
    expect(chain.segments[0].label).toBe('Synchronous call stack');
    expect(chain.segments[1].label).toBe('Async caller (depth 1)');
  });

  it('uses 0-based columns without adjustment', () => {
    const frames = parseFirefoxAsyncStack(STACK);
    expect(frames[0].col).toBe(820);  // no - 1 needed for SpiderMonkey
  });
});

Run with npx vitest run firefox-async-parser.test.ts. Four passing tests confirm the full causal chain is preserved and that column values are left unchanged.

Edge Cases & Gotchas

  • Firefox DevTools vs. Error.stack in production. Firefox DevTools shows async stacks in the debugger UI using the same async* convention, but the DevTools representation may include additional frames injected by the debugger protocol. The Error.stack string captured in production code is the canonical source for server-side symbolication — use it, not a DevTools-copied string.
  • Promise.all and concurrent async paths. When multiple promises race (via Promise.all, Promise.race, or Promise.allSettled), Firefox captures the causal chain of the rejecting promise only. The parallel branches do not appear in the stack. This is not a parser deficiency — it is a fundamental limitation of linear causal-chain capture in concurrent execution.
  • Generator functions and async* string collision. The async* string also appears in SpiderMonkey function names when an async generator is stringified. A function named literally async* myGenerator would produce a line like async* [email protected]:1:100. Distinguish it from a bare async* marker by checking whether the line matches MOZ_FRAME_RE before applying the boundary-increment logic. The current implementation does this correctly: only lines that are exactly async* (no @ character) increment depth.
  • Very long async chains and stack truncation. SpiderMonkey imposes a maximum stack frame count (typically 200 frames) that applies to the combined synchronous and causal chain. Very deep async recursion may produce a truncated stack. The async* separator still appears correctly for the frames that are captured — truncation happens at the end of the array, not at the boundary.
  • unhandledrejection event vs. caught rejections. Firefox only populates causal async frames when the rejection propagates to an unhandledrejection event listener or to a top-level await. Rejections caught with .catch() or try/catch inside an async function may include only the frames up to the catch site, with the causal chain truncated or absent.

FAQ

What is the async* line in a Firefox stack trace?

It is a segment delimiter inserted by SpiderMonkey at every await boundary that the rejection crossed. It marks the point at which synchronous execution suspended and a microtask was scheduled. Frames above the marker executed synchronously in the current microtask; frames below the marker executed in an earlier microtask that scheduled the current one via an await expression.

Does Chrome’s V8 produce async stack frames differently?

Yes. V8 marks individual frames as async using the at async prefix (e.g., at async fetchUser (app.js:1:5900)) rather than inserting a separator line between segments. V8 does not produce a bare async* line. The two engines represent the same information — the causal chain across await boundaries — using incompatible conventions. The depth field in the normalized output serves as a common representation.

Why do some Firefox stacks show no async* markers?

Async stack capture requires the engine’s async debugging mode to be active. In Firefox, this mode is controlled by the devtools.debugger.features.async-stepping preference and is enabled by default in DevTools sessions. In production, without DevTools open, Firefox may omit the causal chain and emit only the synchronous frames. This is a known Firefox behavior, not a bug in your parser. If async causality is critical for production observability, supplement with client-side AsyncLocalStorage (in Node.js) or structured logging that captures the initiating context at scheduling time.