A stack trace is only useful if it reaches back to your code. V8 86,723 gives await "zero-cost" async stack traces: frames resumed after an awaited call are appended and marked at async, so a failure inside node:fs still names the function in your service that asked for the file.
async function readSettings() { return JSON.parse(await readFile('./nope.json', 'utf8')); }
async function bootPlain() { return readSettings(); }
async function bootAwait() { return await readSettings(); }
for (const fn of [bootPlain, bootAwait]) {
try { await fn(); } catch (e) {
console.log(fn.name, '->', e.stack.split('\n').filter((l) => l.includes('async'))
.map((l) => l.trim().split(' ')[2]).join(' / '));
}
}bootPlain -> open / readFile / readSettings / file:///srv/shop/boot.mjs:6:9 bootAwait -> open / readFile / readSettings / bootAwait / file:///srv/shop/boot.mjs:6:9
bootAwait appears in its own stack trace; bootPlain does not. Returning a promise without awaiting it lets the frame be discarded before the rejection travels back, so the trace skips a layer of your own code. return await costs nothing measurable and keeps the frame.
Frames disappear for good across an old-style callback, a setTimeout or an EventEmitter boundary, because the stack was unwound before the callback ran. That is the practical argument for Errors and Causes's cause: re-throw at such a boundary and the wrapper's trace says where you are while the cause's says where it started.
Three knobs change what you see. Error.stackTraceLimit (default 10) caps the captured frames, process-wide via --stack-trace-limit=30. --enable-source-maps points traces at your TypeScript rather than the stripped output, at the cost of latency whenever Error.stack is read. And util.getCallSites() returns frames as objects with functionName, scriptName, lineNumber and columnNumber, beating a regular expression over err.stack; it is still experimental (stability 1.1).