12k
All articles

How to Read a JavaScript Stack Trace

Read JavaScript stack traces: identify the first useful frame, understand async gaps, minified output, source maps, and Error.cause.

OpenReplay Team
OpenReplay Team
How to Read a JavaScript Stack Trace

A JavaScript stack trace reads backwards in time: the top frame is the call that threw, and each frame below it is the call that led there.

The usual move is to skim the first line, paste it into a search box, and hope. That works until the top frame belongs to React, or to JSON.parse, or to a bundle where every function is called o.

This article works through one trace from start to finish, adding a way of reading it in each section: which frame to actually open, what async frames are telling you, what a minified trace looks like, and why the stack behind Error.cause never appears in the stack you printed.

Key Takeaways

  • The top frame is where the error was thrown, not where the bug was introduced; the first frame pointing at a file you wrote is where the investigation starts.
  • Async frames marked async are rebuilt by V8 from the points where each await paused and resumed, so await, Promise.all() and Promise.any() are stitched while a bare .then() chain leaves a gap.
  • V8 keeps only 10 frames by default, a number you can change through the non-standard Error.stackTraceLimit.
  • new Error(message, { cause }) preserves the original error, but the engine does not merge the two stacks: err.stack shows only the wrapper.

What Does a JavaScript Stack Trace Look Like?

A JavaScript stack trace is an error name and message on the first line, followed by one frame per call, newest first. Here is the trace this article keeps returning to: a cart file is read from disk, parsed, and handed to the rest of the app.

SyntaxError: Unexpected token 'b', "{ bad json" is not valid JSON
    at JSON.parse (<anonymous>)
    at parseCart (/app/src/cart.js:5:15)
    at loadCart (/app/src/cart.js:10:20)
    at main (/app/src/main.js:6:16)
    at Object.<anonymous> (/app/src/main.js:12:1)

Read the frames in that order and the chain is plain: JSON.parse threw, parseCart called it, loadCart called parseCart, and so on down. The bottom frame is where that particular chain of calls began, which is not always the program’s entry point: code reached through a click handler, a timer callback or a settled promise gets a fresh chain that starts at the callback.

A single frame gives you four things: the function name, the file, the line and the column. In minified code only the column is worth anything without a source map. One caveat worth knowing early: Error.prototype.stack is not standardised. Every engine ships it, each prints a slightly different string, and the work at TC39 to pin the format down is unfinished. The examples here are V8-shaped, which covers Chrome, Edge and Node.

The Top Frame Is Usually Not Your Code

The top frame of a stack trace is usually not your code. It is the library, framework or built-in that noticed the bad value, which means it tells you what broke, not why. In the trace above, at JSON.parse (<anonymous>) is the built-in parser reporting that a string it was handed is not valid JSON. There is nothing to fix there.

The frame you want is the first one that points at a file you wrote. That is parseCart at /app/src/cart.js:5:15. Open that line and you find the call to JSON.parse, which tells you the bad string arrived as an argument. So the value came from the frame below: loadCart, at line 10, which read the file. That is the real starting point, and the question it answers is where the file contents came from and why nothing validated them.

That reading order generalises. Scan down past node_modules paths, <anonymous> and native frames until you hit your own file, then work downwards through the frames that supplied the value.

A trace records the path the program took into the failure, never the path the user took, which is why two reports with identical frames can be a five-minute fix and an unreproducible ghost. Session replay closes that half of the gap: you read the frames for where it broke and watch the session for how the app reached a state where that could break.

Async Frames and the Await Boundary

Mark loadCart async and the trace spans the await, with the reconstructed frames prefixed async:

SyntaxError: Unexpected token 'b', "{ bad json" is not valid JSON
    at JSON.parse (<anonymous>)
    at parseCart (/app/src/cart.js:5:15)
    at async loadCart (/app/src/cart.js:10:20)
    at async main (/app/src/main.js:6:16)

Those async-prefixed frames are not captured the way synchronous frames are. V8 reconstructs them from the await sites, and it can do that for free because an await picks up again in the very spot where it paused.

The reconstruction has limits, and those limits are where the trace thins out. The stitching reaches await points, Promise.all() and Promise.any(), and nothing else, so a promise returned without being awaited, or a .then() chain, leaves a hole exactly where the calling context used to be. If the frames stop abruptly at an async boundary, look for a missing await in the layer above. There is no flag to turn on: --async-stack-traces has been on by default since V8 v7.3, so async frames appear in Chrome, in all maintained Node releases, and in other current V8 runtimes.

The other reason frames go missing is the cap. V8 keeps 10 frames and drops the rest, and Error.stackTraceLimit is the dial for that number: a new value applies to errors created after you set it, and anything that is not a number, or is below zero, leaves you with no frames at all.

if (process.env.NODE_ENV !== 'production') {
  Error.stackTraceLimit = Infinity;
}

Gate it to development. Deep traces cost memory, bury the interesting frames in noise, and push file paths and function names into logs that may be shipped elsewhere. The property is non-standard: it came out of V8, and JavaScriptCore copied it for compatibility, so setting it breaks nothing anywhere, but the default and the fine detail depend on the engine.

How Do You Read a Minified Trace?

Against a production bundle, the same failure produces frames like this. Treat the shape as illustrative of bundled output rather than the exact format of any one tool:

SyntaxError: Unexpected token 'b', "{ bad json" is not valid JSON
    at JSON.parse (<anonymous>)
    at o (/assets/index-4f1c8a2b.js:1:20874)
    at async s (/assets/index-4f1c8a2b.js:1:21036)

The signature is unmistakable: single-letter function names, one filename, line 1, and a five- or six-digit column. Line 1 and a huge column mean the whole module graph is on one line, so the column is the only coordinate carrying information. parseCart and loadCart still exist in that column offset, but nothing in the string will tell you their names.

Recovering them requires a source map the tooling can reach, generated at build time and uploaded somewhere the trace can be resolved against. Our guide to how source maps work covers the format and the build configuration.

Error.cause Does Not Merge Stacks

Wrapping an error with new Error(message, { cause: originalError }) preserves the original error object along with its type and its own stack, but the engine does not merge the two stacks. err.stack shows only the wrapper, and the original is reachable solely through err.cause.stack.

export async function loadCart(path) {
  const raw = await readFile(path, 'utf8');
  try {
    return parseCart(raw);
  } catch (err) {
    throw new Error(`Cart file ${path} is not valid JSON`, { cause: err });
  }
}

Callers now get a message naming the file, and err.cause instanceof SyntaxError still holds. What they do not get is the JSON.parse frame: the wrapper’s stack starts at the throw inside loadCart. Error.cause arrived in ES2022 and works in current browsers and in every maintained Node release, going back to Node 16.9.0. It lands as an own property that is not enumerable, so it stays out of Object.keys(), for...in and a naive JSON.stringify() of the error.

How much of a chain a console prints varies by runtime and by console, so the portable move is to walk it yourself:

function printChain(error) {
  let current = error;
  while (current instanceof Error) {
    console.error(current.stack);
    current = current.cause;
  }
}

That prints the wrapper’s frames, then the parser’s, in the order they were thrown.

Two Habits That Destroy the Trail

Catching an error and throwing a new one without passing a cause deletes the original trace from the program. The frames that would have told you where the bad value entered no longer exist anywhere, and no amount of log searching brings them back:

catch (err) {
  throw new Error('Could not load cart');   // JSON.parse frame is gone
}

Swallowing the error into a log line does the same damage more quietly:

catch (err) {
  console.log('cart load failed');          // message, no stack, no type
  return [];
}

Both are one keyword away from being fine. Pass { cause: err } when you rethrow, and log err itself rather than a sentence about it.

Next time a trace lands in front of you, do not start at line one. Scroll down to the first frame with your own filename on it, open that line, and ask what value it was handed and by whom. If the frames stop at an async boundary or a single-letter function, you are looking at a stitching gap or a missing source map, not at the whole story.

FAQs

Why does my error handler only report 'Script error.' with no stack?

Browsers mask the details of exceptions thrown by cross-origin scripts, so window.onerror receives the generic 'Script error.' text with no useful URL, line number or stack. To get the real message and frames, load the script with the crossorigin attribute set to anonymous and make sure the server hosting it returns an Access-Control-Allow-Origin header covering your origin. Most public CDNs already send that header.

Does Error.captureStackTrace work outside Chrome and Node?

It is no longer V8 only. Error.captureStackTrace began life in V8 as part of its non-standard stack trace API, and the other engines have since followed: JavaScriptCore shipped it in Safari 17.2, released on 11 December 2023, and SpiderMonkey in Firefox 138, released on 29 April 2025. Calling it writes a stack string onto whatever object you pass in. Because it is still unstandardised, guard the call with a typeof Error.captureStackTrace check before using it in shared library code.

Why do stack traces look different in Firefox than in Chrome?

Error.prototype.stack sits outside every specification, so each engine prints it the way it likes and the contents vary. V8 writes each frame on a line beginning with 'at', while Firefox uses a functionName@file:line:column form with no such prefix. Treat the stack string as human readable output, not a parseable API, and never build error grouping on a hand-rolled regex alone.

Can I capture a stack trace without throwing an error?

Yes. In most current engines the stack is filled in when you construct the Error, not when you throw it, so const { stack } = new Error() hands you the call stack on the spot, with no throw and no catch. The top frame is the line that created the error, and the usual frame cap still applies: V8 keeps only 10 frames unless Error.stackTraceLimit is raised.

Open-source session replay

Complete picture for complete understanding

Capture every clue your frontend is leaving so you can instantly get to the root cause of any issue with OpenReplay — the open-source session replay tool for developers. Self-host it in minutes, and have complete control over your customer data.

Star on GitHub12k

We use cookies to improve your experience. By using our site, you accept cookies.