Reading the debugger's Call Stack: frames, switching context, Just My Code and external code, async logical stacks and Parallel Stacks, inlining, and exception stack traces (throw vs throw ex).
The Call Stack shows every method that is currently active on the thread, newest at the top: who called whom to reach this line. I double-click a lower frame to jump to that caller, and Locals and Watch switch to that frame's variables, which is how I find where a bad value came from. Just My Code hides framework frames as [External Code]. For async code the real thread stack after an await starts in the thread pool, so Visual Studio stitches the logical async chain back together, and Parallel Stacks shows it across tasks. In Release builds, inlining can hide frames.
"The Call Stack shows every method that's currently active on the thread, newest at the top, so it tells me who called whom to reach this line. It isn't a history, though: methods that have already returned aren't on it.
I double-click a lower frame to switch context, and the editor, Locals and Watch follow it, which is how I walk down the frames to find where a bad value first went wrong. Just My Code treats only my non-optimised code with symbols as user code and collapses the rest into External Code, and I show external code when the question is about framework behaviour. In Release builds, inlining can make frames disappear, so a stack can look impossible.
For async code, after an await the physical stack starts at a thread pool callback, not at my controller, so the debugger rebuilds the logical async chain from Task objects and their continuations. With many concurrent requests I use Parallel Stacks with the Tasks view, which is also where a deadlock or a hung await chain shows up. Exception stack traces are captured at throw time, and the mistake I see is rethrowing with
throw ex;, which resets the trace, instead ofthrow;, which keeps it.In production there's no live stack, so I combine logged stack traces with structured logs and trace ids, and capture memory dumps for hangs: the stack says where, and the logs say what and why."
Each row is a stack frame: a method that has been called and has not returned yet, with the module, the method and the current line. The top frame is where the thread is now; each frame below is its caller. Only the current thread is shown; switch threads in the Threads window or the toolbar dropdown.
With Just My Code on (the default), frames from code without symbols or from optimised framework assemblies collapse into [External Code]. Right-click → Show External Code reveals ASP.NET Core, Kestrel or EF Core frames when you need to understand framework behaviour.
After an await, a method continues on whichever thread the scheduler chooses, so the physical stack starts at a thread pool work item, not at your controller. The compiler turns async methods into state machines (MoveNext), and the debugger uses task information to show the logical chain: which async method is awaiting which. For many concurrent requests, Debug → Windows → Parallel Stacks with the Tasks view groups tasks by their async stacks, which makes a hung await chain easy to spot.
An exception's StackTrace is the call stack captured when it was thrown. Re-throwing with throw; preserves it; throw ex; resets it to the re-throw location and loses the original frames. With Break when thrown (Exception Settings), the debugger stops at the throw with the full live call stack, which is more useful than the trace you see later.
A payments API sometimes returned a 500 with a stack trace pointing only at a global error handler. The handler did catch (Exception ex) { ...; throw ex; }, which reset the trace. Changing it to throw; restored the original frames and showed the failure was in a currency conversion helper three calls deep.
The interviewer wants to see that you can read stacks fluently, including the async case that confuses many developers.
Green flags
throw; versus throw ex; and Just My Code.Red flags
MoveNext frames.throw ex;.Every time a method is called, the thread pushes a frame onto its stack: the return address, saved registers, and space for locals and arguments. When the method returns, the frame is popped. The Call Stack window is a view of the frames that are still active: methods that have been called and haven't returned yet.
Thread 7 stack (grows downwards) Call Stack window (top = newest)
+------------------------------+ > Pricing.ApplyGst(amount) line 18
| Main locals, return | Pricing.Total(lines) line 11
+------------------------------+ Program.<Main>$(args) line 4
| Total locals, return | [External Code]
+------------------------------+
| ApplyGst locals, return | <-- instruction pointer
+------------------------------+
It is not a history. A method that already returned (for example a validation method that set a bad value and finished) isn't on the stack any more. To find that, you need a breakpoint inside it, a data breakpoint on the value, or logs.
[External Code].Without symbols, a frame shows the method name but no line number. Without the module loaded, it can't be resolved at all.
frame 0: ApplyGst(amount = -5) <-- crash here: negative amount
frame 1: Total(lines = [100, 250, -5]) <-- lines already contained -5
frame 2: PriceOrder(order) <-- order.Lines came from the mapper
frame 3: OrdersController.Post(dto) <-- dto.Lines[2].Amount = -5 from the client
Double-click each frame. The editor, Locals, Autos and Watch switch to that frame, so you can see the value at each level and find where it first went wrong. In this example the fix is input validation at the controller, not a change in ApplyGst.
Other useful actions:
| Action | What it does |
|---|---|
| Run to Cursor on a frame | Continue until execution returns to that caller |
| Show External Code | Reveal framework frames (Kestrel, middleware, EF Core) |
| Copy All | Paste the stack into a bug report or pull request |
| Show Parameter Values / Types | Add arguments to each row |
| Threads dropdown | Switch to another thread's stack |
The compiler turns each async method into a state machine with a MoveNext() method. Each await that doesn't complete immediately returns to the caller and registers a continuation. Later, a thread pool thread calls MoveNext() to resume.
Physical stack after an await (thread pool thread):
ApplyGstAsync.MoveNext() <-- your code resumes here
AsyncTaskMethodBuilder ... RunOrScheduleAction
ThreadPoolWorkQueue.Dispatch()
[thread start]
(no controller, no service: they aren't on THIS thread)
Logical async chain the debugger rebuilds:
PricingService.ApplyGstAsync (running)
PricingService.PriceAsync awaiting ApplyGstAsync
OrdersController.PostAsync awaiting PriceAsync
The debugger rebuilds the logical chain from task objects: each state machine holds a reference to the continuation that resumes its caller. Visual Studio marks frames such as [Resuming Async Method] and shows the async callers below them.
Debug > Windows > Parallel Stacks has two views:
| View | Shows | Best for |
|---|---|---|
| Threads | Each thread's stack, with identical stacks merged into one box and a count | Deadlocks and blocked threads ("40 threads in Monitor.Enter") |
| Tasks | Async logical stacks, grouped | Hung await chains with no thread running them |
A hung async request often has no thread at all: it is a task waiting for a continuation that never runs. Only the Tasks view (or dumpasync in a memory dump) shows it.
| Optimisation | Effect on the stack |
|---|---|
| Inlining | Small methods disappear; their code appears inside the caller |
| Tail calls | A caller frame can be replaced by the callee |
| Tiered compilation | Methods start unoptimised and are re-JITted with optimisations, so behaviour can differ over time |
If a stack looks impossible, check whether you are debugging an optimised build. Visual Studio's Suppress JIT optimization on module load option gives unoptimised code under the debugger, at the cost of behaviour that differs from production. [MethodImpl(MethodImplOptions.NoInlining)] keeps a specific method visible, but use it for diagnosis only.
An exception's StackTrace is captured as it unwinds from the throw point to the catch.
| Rethrow style | Original frames kept? | Use |
|---|---|---|
throw; |
Yes | Default for rethrowing in a catch block |
throw ex; |
No: the trace restarts at this line | Never for rethrowing |
throw new OrderException("...", ex); |
Yes, in InnerException |
When you add context |
ExceptionDispatchInfo.Capture(ex).Throw(); |
Yes, plus a marker line | Rethrowing later or on another thread |
In async code, await rethrows using ExceptionDispatchInfo, so traces include lines such as --- End of stack trace from previous location --- where the exception crossed an await. Modern .NET removes most state machine noise from these traces, so they read close to the logical call chain.
[StackTraceHidden] on a method hides it from exception stack traces, which is useful for throw helpers.
In Exception Settings (Ctrl+Alt+E in Visual Studio), tick an exception type under Break When Thrown. The debugger stops at the throw point with the live call stack, every frame's locals and the full async chain. That is far more useful than the text trace you read after the exception has been caught, logged and rethrown.
ILogger with structured properties (order id, trace id) so a stack trace in Application Insights or another APM tool can be joined to the request and its dependencies.dotnet-dump analyze with clrstack -all for thread stacks and dumpasync for async chains.Capturing a stack trace is expensive: new StackTrace(), Environment.StackTrace and every thrown exception walk the stack. Don't use exceptions for normal control flow in hot paths, and don't log Environment.StackTrace on every request.
The stack model is stable. Watch the .NET 11 release notes for exception handling and async improvements that change how traces read, and the IDE release notes for Call Stack and Parallel Stacks features.
InvalidOperationException.throw; to catch (InvalidOperationException ex) { throw ex; }, run without breaking, and compare the printed traces.ApplyGstAsync still appears as its own frame.An async chain with a rethrow to explore in the Call Stack and Parallel Stacks windows:
var service = new PricingService();
try
{
var total = await service.PriceAsync([100m, 250m, -5m]);
Console.WriteLine(total);
}
catch (InvalidOperationException ex)
{
Console.WriteLine(ex.StackTrace); // compare `throw;` with `throw ex;`
}
public sealed class PricingService
{
public async Task<decimal> PriceAsync(IReadOnlyList<decimal> lines)
{
try
{
decimal total = 0;
foreach (var line in lines)
{
total += await ApplyGstAsync(line);
}
return total;
}
catch (InvalidOperationException)
{
// log here, then rethrow without losing the original stack trace
throw;
}
}
private static async Task<decimal> ApplyGstAsync(decimal amount)
{
await Task.Delay(10); // forces a real asynchronous continuation
if (amount < 0)
{
throw new InvalidOperationException($"Negative line amount {amount}.");
}
return Math.Round(amount * 1.10m, 2);
}
}
Mistake: reading only the top frame. The top frame is where the failure shows up, not usually where the bad value was created. Instead: double-click down the frames, check Locals in each, and find the first frame where the value is already wrong.
Mistake: thinking the Call Stack shows history. Methods that already returned aren't on it, so the code that set a bad value may be invisible. Instead: set a breakpoint inside the suspected method, use a data breakpoint on the value, or add a tracepoint.
Mistake: expecting the physical stack after await to show your callers.
The thread that resumes your method starts in the thread pool; your controller and service aren't on that thread at all.
Instead: read the logical async frames in the Call Stack, or use Parallel Stacks with the Tasks view.
Mistake: rethrowing with throw ex;.
It resets the stack trace to the rethrow line and hides where the error actually happened.
Instead: use throw;, or throw new SpecificException("context", ex) when you add information. Use ExceptionDispatchInfo.Capture(ex).Throw() to rethrow later.
Mistake: never showing external code. With Just My Code, framework frames are collapsed, so you can't see middleware order, filters or which framework path called your code. Instead: right-click and choose Show External Code when the question is about framework behaviour.
Mistake: assuming Just My Code means the bug is in your code.
Exceptions can originate inside a library; the collapsed [External Code] row is still part of the path.
Instead: show external code, load symbols or use Source Link to step into the library when the trace points there.
Mistake: trusting stacks from optimised builds. Inlining and tail calls remove frames, so a stack can look impossible. Instead: reproduce on a Debug build, or turn on Suppress JIT optimization while diagnosing.
Mistake: looking at the wrong thread. The Call Stack shows only the current thread, which may not be the one you care about after a breakpoint hit on another thread. Instead: check the Threads window or dropdown, or use Parallel Stacks to see every thread at once.
Mistake: relying on the text trace instead of breaking when thrown. By the time you read a logged trace, the locals and async chain are gone. Instead: enable Break When Thrown for the exception type in Exception Settings to stop at the throw with the live stack.
Mistake: logging stack traces without context in production.
A trace says where, not which order, user or request.
Instead: log through ILogger with structured properties and a trace id, and deploy PDBs so traces have line numbers.
Seniors use the call stack as a map of the system's behaviour, not just of a bug: setting a breakpoint deep inside a library and reading the stack is often the fastest way to learn how a framework works. For async-heavy services they rely on Parallel Stacks' Tasks view and, in production, on dump analysis (dumpasync in dotnet-dump) because hung requests have no thread on the stack at all.
In production the stack trace in a log is rarely enough on its own. They log exceptions through ILogger with structured properties (order id, customer id hashed, trace id) so a stack trace in Application Insights can be joined to the request, its dependencies and the user's journey; the stack says where, the logs and traces say what and why. They keep PDBs with deployments so logged traces have line numbers.
They also make stacks useful for others: rethrow with throw;, wrap with new XException(message, inner) when adding context, and keep exception messages specific so the trace plus the message pinpoints the failure.
When it hangs, I press Break All so every thread stops where it is. In the Threads window I look for threads sitting in Monitor.Enter, Lock.Enter or Task.Wait, and switch to each one to read its call stack. Parallel Stacks shows all thread stacks merged into one graph, so two threads each holding a lock and waiting for the other's lock stand out immediately. That's the classic opposite-order deadlock. In production I can't attach, so I take a dump with dotnet-dump and run clrstack -all and syncblk. The fix is a consistent lock order, smaller locks, or removing sync-over-async.
When I can't attach a debugger, I take a dump: a snapshot of the process's memory, threads and stacks that I analyse offline. For a hang or a leak, dotnet-dump collect -p <pid> does it in seconds. For crashes, I enable automatic crash dumps with the DOTNET_DbgEnableMiniDump environment variables. On App Service I use Diagnose and solve problems, and in Kubernetes dotnet-monitor. Then I analyse with dotnet-dump analyze: clrstack -all and syncblk for hangs, dumpheap -stat and gcroot for leaks, pe for exceptions. Or I open the dump in Visual Studio and use the Threads and Parallel Stacks windows. Dumps contain customer data, so they're handled as sensitive.
Last updated 1 Nov 2026.