
The Case of the Disappearing Stack Trace: How I Finally Mastered Error Causality in JavaScript
Stop losing the original context of your failures to generic wrapper objects and fragmented logs.
Nothing kills a Friday afternoon faster than an error log that tells you absolutely nothing about *why* something broke. You see a generic Error: Database operation failed in your logs, but the actual root cause—the network timeout, the auth failure, or the malformed query—has vanished into the digital void.
For years, JavaScript developers have been trapped in a cycle of "re-throwing" errors and inadvertently nuking their stack traces. We’ve all been there: you catch an error, want to provide a more helpful context for the specific function, and end up creating a new error that hides the original crime.
The Tragedy of the "Wrapper" Error
Let's look at the classic way many of us learned to handle errors. We want our service layer to be clean, so we wrap low-level errors in something more descriptive.
async function getUserProfile(userId) {
try {
const user = await db.fetchUser(userId); // This might throw a connection error
return user;
} catch (err) {
// We want to be helpful, so we throw a new error
throw new Error(`Failed to retrieve profile for user ${userId}`);
}
}When this code fails, your logs will show Error: Failed to retrieve profile for user 123. But what happened to the original db.fetchUser error? It’s gone. You don't know if the database was down, if the query timed out, or if there was a syntax error in your SQL.
You’ve successfully sanitized your logs, but you’ve also blinded your future self.
The Old (and Messy) Workaround
Before ES2022, we had to get creative. Some developers would manually attach the original error to the new one, or create custom error classes that felt like over-engineering just to save a stack trace.
try {
// ... something risky
} catch (err) {
const wrapperError = new Error('Something went wrong');
wrapperError.originalError = err; // The "manual" way
throw wrapperError;
}This worked, but it wasn't a standard. Different libraries used different names (originalError, innerError, source). Tools like Sentry or Datadog didn't always know where to look, so you’d still end up clicking through fragmented logs trying to piece the story together.
The Hero We Needed: Error.cause
With the arrival of ES2022, JavaScript finally gave us a native way to handle error chaining. It’s remarkably simple: the Error constructor now accepts an options object as a second argument, where you can pass a cause.
Here is how you fix that getUserProfile function properly:
async function getUserProfile(userId) {
try {
return await db.fetchUser(userId);
} catch (err) {
throw new Error(`Failed to retrieve profile for user ${userId}`, { cause: err });
}
}By adding { cause: err }, you aren't just throwing a new error; you're building a linked list of failures.
Why this is a game-changer
When you log this error in modern environments (Chrome DevTools, Node.js 16.9+, etc.), the console doesn't just show the top-level error. It shows the whole chain.
try {
await getUserProfile(123);
} catch (err) {
console.error(err);
/*
Output looks something like:
Error: Failed to retrieve profile for user 123
at getUserProfile (file.js:10:11)
Caused by: TypeError: Cannot read properties of undefined (reading 'query')
at db.fetchUser (database.js:2:15)
*/
}Mastering the Chain in Real-World Scenarios
It’s not just about catching one error. Sometimes errors happen in layers. Imagine a scenario where a UI component calls an API service, which calls a fetch utility, which eventually fails.
// Level 1: Low-level fetch utility
async function rawFetch(url) {
try {
return await fetch(url);
} catch (err) {
throw new Error("Network request failed", { cause: err });
}
}
// Level 2: API service
async function updateSettings(settings) {
try {
return await rawFetch("/api/settings", { method: 'POST', body: settings });
} catch (err) {
throw new Error("Could not update user settings", { cause: err });
}
}
// Level 3: UI Component
try {
await updateSettings({ theme: 'dark' });
} catch (err) {
// Now we have the full context:
// UI Error -> API Error -> Network Error -> Original TypeError
reportToSentry(err);
}Pro-Tip: Recursive Error Logging
If you’re building your own logging utility, you can easily traverse these causes to build a complete picture of what went wrong. Since cause is just a property, you can loop through it.
function logFullError(err) {
let currentError = err;
let depth = 0;
while (currentError) {
console.log(`${" ".repeat(depth)}[Level ${depth}] ${currentError.message}`);
currentError = currentError.cause;
depth++;
}
}A Few Gotchas to Keep in Mind
1. Serialization: If you're sending errors over the wire (like JSON-stringifying an error to send to a logging endpoint), cause might not be enumerable by default in some environments. You might need a helper to ensure the chain is preserved in your JSON payload.
2. Compatibility: If you’re supporting very old browsers (IE11, we see you), you’ll need a polyfill. Most modern projects using TypeScript or Babel will handle the transpilation gracefully, but it's worth a check in your tsconfig.json.
3. Don't over-wrap: Just because you *can* wrap every error doesn't mean you *should*. Use cause when the context changes—moving from "Database Error" to "User Service Error" makes sense. Moving from "Database Error" to "Another Database Error" just adds noise.
The Bottom Line
The next time you're tempted to just throw new Error('Something went wrong'), take two seconds to add that second argument. Your future self—staring at a production bug at 4:00 PM on a Friday—will thank you for leaving a breadcrumb trail back to the truth.
Stop losing the stack trace. Use .cause.


