Fix Common Errors

"End of stack trace from previous location" in .NET

This line marks where an async exception was rethrown after an await — not a bug. Learn to read async stack traces and keep the original context intact.

When you see "End of stack trace from previous location" in a .NET exception report, your first instinct is probably "Something went wrong." In reality, this line is not an error. It's a marker the .NET runtime inserts to show you that an exception was captured and rethrown across an await boundary. Understanding this marker and what it means for your async stack traces is essential for debugging production errors effectively, especially when tracing through complex async flows where the exception originates in one part of your code but surfaces in another.

The marker appears because of how the .NET runtime handles exception preservation in async methods. When you await a Task that throws, the exception must be rethrown on a different call stack—the continuation that resumes after the await. The runtime uses internal machinery (ExceptionDispatchInfo.Capture(ex).Throw()) to re-throw the original exception while preserving its stack trace. That line divides the stack: above it is where the exception actually originated; below it is where execution resumed after the await. Once you learn to read it, the marker becomes a compass pointing you to the real bug.

Why the Marker Exists

In synchronous code, an exception follows a single stack from throw to catch. In async code, the stack is fragmented. Consider this flow:

public async Task ProcessUserAsync(int userId)
{
    var user = await FetchUserAsync(userId);  // Await point A
    var profile = await FetchProfileAsync(user);  // Await point B
    // ...
}

private async Task<User> FetchUserAsync(int id)
{
    throw new ArgumentException("User not found");
}

When FetchUserAsync throws, the exception is stored inside a Task object. When you await that Task at point A, the runtime retrieves the exception and rethrows it on your continuation—the code that resumes after the await. But the continuation is executing on a different stack frame than where the exception was originally thrown.

The .NET runtime inserted --- End of stack trace from previous location --- to mark this boundary, preserving both the original throw site and the resumption point in a single readable stack trace.

Reading a Real Async Stack Trace

Here's what one looks like:

System.ArgumentException: User not found
   at LightTrace.Data.FetchUserAsync(Int32 id) in /app/Data.cs:line 42
   at LightTrace.Data.FetchUserAsync(Int32 id) in /app/Data.cs:line 40
   --- End of stack trace from previous location where exception was thrown ---
   at LightTrace.Service.ProcessUserAsync(Int32 userId) in /app/Service.cs:line 15
   at LightTrace.Service.ProcessUserAsync(Int32 userId) in /app/Service.cs:line 15
   at LightTrace.Program.Main() in /app/Program.cs:line 28

Above the marker (lines 1-2): The original exception and where it was thrown (FetchUserAsync line 42). This is your evidence.

Below the marker (lines 4-6): Where the exception was caught and rethrown (ProcessUserAsync line 15), plus the rest of the call stack. Notice MoveNext and compiler-generated frames—those are part of the async state machine.

The real bug is in FetchUserAsync. The fact that it surfaced in ProcessUserAsync is expected; ProcessUserAsync awaited a faulted Task. If you only read below the marker, you'd miss the root cause.

The Critical Difference: throw; vs throw ex;

Here's the most actionable takeaway for production debugging: always use bare throw; when rethrowing.

public async Task ProcessUserAsync(int userId)
{
    try
    {
        var user = await FetchUserAsync(userId);
    }
    catch (ArgumentException ex)
    {
        // GOOD: Rethrow without resetting the stack
        throw;
    }
}

If you write throw ex; instead:

catch (ArgumentException ex)
{
    // BAD: Resets the stack trace, loses evidence
    throw ex;
}

The throw ex; statement resets the stack trace. The new throw becomes the origin, and you lose the marker and everything above it. Your error tracker now thinks the exception started here, not in FetchUserAsync. You've destroyed your evidence.

This is such a common mistake in production code that most error tracking dashboards have to work around it. When reviewing exception reports, check whether the stack trace was reset and you're looking at a dead-end. It also degrades how errors get grouped: if every rethrow reports the same rethrow site, genuinely different bugs collapse into one issue.

Rethrowing with ExceptionDispatchInfo

If you need to capture an exception and rethrow it much later (not at an immediate catch point), use ExceptionDispatchInfo:

ExceptionDispatchInfo capturedEx = null;

public async Task DoWorkAsync()
{
    try
    {
        var result = await SomeFailingAsync();
    }
    catch (Exception ex)
    {
        capturedEx = ExceptionDispatchInfo.Capture(ex);
    }
}

public void RethrowLater()
{
    if (capturedEx != null)
    {
        capturedEx.Throw();  // Rethrows with the original stack preserved
    }
}

The Throw() method preserves the original stack trace just like bare throw; does, but lets you defer the rethrow to a different time and place.

.Result and .Wait() Hide the Real Trace

If you block on a Task synchronously—using .Result or .Wait()—the exception gets wrapped in an AggregateException:

public void SyncWrapper()
{
    try
    {
        var result = FetchUserAsync(123).Result;  // WRONG: Blocks and wraps in AggregateException
    }
    catch (AggregateException aggEx)
    {
        // Now the real exception is buried in InnerException
        Console.WriteLine(aggEx.InnerException.Message);
    }
}

You lose the async context, hide the real exception, and create deadlock risk (if the Task depends on a synchronization context). Await the Task instead; it unwraps the exception for you.

Noise Frames and State Machines

Async stack traces often contain frames like:

  • System.Runtime.CompilerServices.TaskAwaiter.ThrowForNonSuccess
  • System.Runtime.CompilerServices.TaskAwaiter.HandleNonSuccessAndDebuggerNotification
  • MyClass.MoveNext() (the compiler-generated state machine method)

These are noise. Skip them and find your own code. The real information is in the frames outside System.Runtime.CompilerServices. Error trackers like LightTrace filter these by default so you don't have to.

When reading a stack trace, look for your own code (frames from your assembly/namespace) and ignore the runtime machinery. The marker separates original-throw from continuation; the real bug is almost always above the marker in user code.

Modern .NET Improvements

Starting in .NET 5+, the marker text was shortened and stack traces became cleaner. Microsoft also introduced [StackTraceHidden]:

[StackTraceHidden]
private async Task<User> FetchUserInternalAsync(int id)
{
    return await database.GetUserAsync(id);
}

Frames marked with [StackTraceHidden] are omitted from stack traces, reducing noise. This is especially useful in library code where you want to hide boilerplate and make caller stacks more readable.

Newer versions of .NET also improved async stack trace collection in the debugger and diagnostic tools, making it easier to see the full flow without manually parsing compiler-generated frame names.

Why This Matters in Production

In production, you can't step through a debugger. You rely on stack traces to tell you where bugs happen. A long async stack trace with the marker is good—it gives you the full context. A reset stack trace (from throw ex;) or a wrapped exception (from .Result) is bad—it hides the evidence.

That's why proper error tracking is critical. LightTrace automatically captures full stack traces, shows you the marker and context, and groups exceptions by fingerprint so you see which async bugs are hitting production most. When you examine an issue in the dashboard, you can see both the original exception site and the path it took through your code.

Error trackers parse stack traces intelligently. They skip noise frames, identify the real origin, and link to your source code so you can jump straight to the line that threw. The "End of stack trace from previous location" marker becomes transparent once your tool understands it.

Tying It Together

The marker is a feature, not a bug. It tells you that the .NET runtime successfully preserved your exception across an async boundary. When you see it:

  1. Above the marker: the exception originated here.
  2. Below the marker: where control resumed after the await.
  3. Use bare throw; always, never throw ex;.
  4. Avoid .Result / .Wait() on faulted Tasks; they hide the real stack.
  5. Filter noise (compiler-generated frames) and focus on your code.

Once you internalize this, async stack traces become as readable as synchronous ones. And with proper error tracking and stack-trace reading skills, you'll find bugs faster.

Never reset your stack trace with throw ex;. If you're reviewing legacy code and spot it, make it a priority to fix. Every instance is destroying evidence that production bugs depend on.

Start tracking errors in minutes

When async bugs land in production, you need a dashboard that shows you the full stack with the marker intact and source lines linked. Start a free LightTrace account to see your .NET exceptions with complete context, automatically grouped by root cause.

Fix your next production error faster

Point any Sentry SDK at LightTrace — free up to 5,000 events/month.