Reading Error Messages and Stack Traces: .NET Exceptions, Async Stacks and Angular NG Errors
A stack trace is read layer by layer, not top to bottom: the real cause usually sits in the inner exception starting with --->, and the root of 50 identical errors in a log is usually the first error in time order. With real output from .NET 10 we read InnerException chains, AggregateException, the stack lost to throw ex, ExceptionDispatchInfo, going from a ProblemDetails traceId to the log, source maps, and Angular 22's NG0100, NG0200 and NG0201 errors.
Version note: The code in this post was built and run with .NET 10; the Angular examples ran on Angular 22.2 with Vitest 5.1, and the JavaScript example on Node.js 24 with esbuild 0.28.
Guess First
The Bookstore API (Application Programming Interface) log has 51 fail: lines from the last five minutes. Fifty of them are identical: System.NullReferenceException: Object reference not set to an instance of an object., all inside PriceCatalog.GetPrice. One is different and sits at the top. Everyone on the team is staring at the error that repeats 50 times, because it fills the screen. Which line do you think solves this bug: the most frequent one or the first one?
Second question: in a catch block you log the exception and rethrow it with throw ex;. How many lines of the stack trace show the method where the error actually happened? We'll answer both with real output from this post.
Anatomy of an Exception: Type, Message, Stack Trace and the Inner Exception Chain
The mechanics of catching and throwing exceptions are covered in the C# Fundamentals series post "Exception Handling in C#: try/catch/finally, throw and Your Own Exception". Our job here is reading. In Bookstore, checkout calls the payment provider; when the provider returns 503, CheckoutService wraps the error in its own exception:
public sealed class PaymentFailedException(string message, Exception inner) : Exception(message, inner);
public sealed class PaymentGateway
{
public async Task ChargeAsync(int orderId, decimal amount)
{
await Task.Delay(10);
throw new HttpRequestException("Response status code does not indicate success: 503 (Service Unavailable).");
}
}
public sealed class CheckoutService(PaymentGateway gateway)
{
public async Task CheckoutAsync(int orderId)
{
try
{
await gateway.ChargeAsync(orderId, 349.90m);
}
catch (HttpRequestException ex)
{
// carry it as the inner exception so the real cause is not lost
throw new PaymentFailedException($"Payment for order {orderId} failed.", ex);
}
}
}
The real output of ex.ToString() in a Debug build (file paths shortened):
Bookstore.Errors.PaymentFailedException: Payment for order 1042 failed.
---> System.Net.Http.HttpRequestException: Response status code does not indicate success: 503 (Service Unavailable).
at Bookstore.Errors.PaymentGateway.ChargeAsync(Int32 orderId, Decimal amount) in /src/errors/Program.cs:line 44
at Bookstore.Errors.CheckoutService.CheckoutAsync(Int32 orderId) in /src/errors/Program.cs:line 54
--- End of inner exception stack trace ---
at Bookstore.Errors.CheckoutService.CheckoutAsync(Int32 orderId) in /src/errors/Program.cs:line 58
at Program.<Main>$(String[] args) in /src/errors/Program.cs:line 11
You don't read this text top to bottom; you read it layer by layer. The first line is the outermost exception: its type and message. The line starting with ---> tells you the inner exception starts here; its stack trace runs until --- End of inner exception stack trace ---. The frames after that belong to the outer exception again. So the real cause sits in the middle, between the two separators: the payment provider returned 503, and the error was born on line 44. If the chain is longer, every ---> goes one level deeper; the innermost exception is usually the cause, the outer ones are context.
Look at three things while reading frames. First, the in ...:line 44 part only appears when a PDB (Program Database, the symbol file) is available; Release builds produce PDBs too, but if they weren't copied to the server, line numbers are gone. Second, for async methods you don't see the MoveNext frames of the state machine the compiler generated; the runtime turns them into readable method names, so you see CheckoutAsync. Third, names with angle brackets such as Program.<Main>$ are compiler-generated code: top-level statements here, lambdas (<>c__DisplayClass) and local functions elsewhere.
AggregateException and the Single Error await Picks
When several tasks run in parallel and two of them blow up, what you need to read changes. First, blocking synchronously:
public static void AggregateWithWait()
{
Task a = FailAsync("Inventory service timed out.");
Task b = FailAsync("Shipping quote failed.");
Task.WaitAll(a, b);
}
System.AggregateException: One or more errors occurred. (Inventory service timed out.) (Shipping quote failed.)
---> System.TimeoutException: Inventory service timed out.
at Bookstore.Errors.Demos.FailAsync(String message) in /src/errors/Program.cs:line 95
--- End of inner exception stack trace ---
at System.Threading.Tasks.Task.WaitAllCore(ReadOnlySpan`1 tasks, Int32 millisecondsTimeout, CancellationToken cancellationToken)
at System.Threading.Tasks.Task.WaitAll(ReadOnlySpan`1 tasks)
at Bookstore.Errors.Demos.AggregateWithWait() in /src/errors/Program.cs:line 69
at Program.<Main>$(String[] args) in /src/errors/Program.cs:line 14
---> (Inner Exception #1) System.TimeoutException: Shipping quote failed.
at Bookstore.Errors.Demos.FailAsync(String message) in /src/errors/Program.cs:line 95<---
The message of an AggregateException lists every inner message in parentheses; that is how you answer "how many things failed" from a single log line. The first inner exception appears in the classic chain format, the second and later ones at the bottom under an (Inner Exception #1) header. If you only look at the first inner exception in the log and open a ticket for "inventory timeout", you miss the second problem on the shipping side.
Awaiting the same two tasks with Task.WhenAll changes the picture:
Task all = Task.WhenAll(a, b);
try
{
await all;
}
catch (Exception ex)
{
Console.WriteLine($"caught: {ex.GetType().Name}: {ex.Message}");
Console.WriteLine($"all.Exception: {all.Exception!.InnerExceptions.Count} inner");
foreach (Exception inner in all.Exception.InnerExceptions)
{
Console.WriteLine($" - {inner.Message}");
}
}
caught: TimeoutException: Shipping quote failed.
all.Exception: 2 inner
- Shipping quote failed.
- Inventory service timed out.
await unwraps the AggregateException and throws only one of them. Don't rely on which one: in this run "Shipping" came first because that task completed first. The other errors are not lost; they live in the task's Exception property, but if you don't log it they never show up in the log. The details are in the C# Async series post "Exceptions in C# Async: AggregateException and Errors in WhenAll". The reading rule: in code that uses WhenAll, the single exception in the log may be just one of several failures.
throw; or throw ex;: The Lost Stack Trace
Logging and rethrowing is common, and a one-word difference takes away half of the stack trace:
public static void Rethrow(bool useThrowEx)
{
try
{
LoadPrices();
}
catch (Exception ex)
{
Console.WriteLine("logging and rethrowing...");
if (useThrowEx)
{
throw ex;
}
throw;
}
}
private static void LoadPrices() => ParsePrice("12,5O");
private static decimal ParsePrice(string raw) =>
decimal.Parse(raw, System.Globalization.CultureInfo.InvariantCulture);
With throw;:
System.FormatException: The input string '12,5O' was not in a correct format.
at System.Number.ThrowFormatException[TChar](ReadOnlySpan`1 value)
at System.Number.ParseDecimal[TChar](ReadOnlySpan`1 value, NumberStyles styles, NumberFormatInfo info)
at Bookstore.Errors.Demos.ParsePrice(String raw) in /src/errors/Program.cs:line 118
at Bookstore.Errors.Demos.LoadPrices() in /src/errors/Program.cs:line 115
at Bookstore.Errors.Demos.Rethrow(Boolean useThrowEx) in /src/errors/Program.cs:line 102
at Program.<Main>$(String[] args) in /src/errors/Program.cs:line 20
With throw ex;:
System.FormatException: The input string '12,5O' was not in a correct format.
at Bookstore.Errors.Demos.Rethrow(Boolean useThrowEx) in /src/errors/Program.cs:line 109
at Program.<Main>$(String[] args) in /src/errors/Program.cs:line 23
According to the second output the error was born on line 109 of Rethrow, yet that line is just throw ex;. The path to ParsePrice, LoadPrices and the letter "O" inside the price is gone. Someone who starts debugging from this stack trace looks at the wrong method first. The compiler already tells you this; the real warning from building the project:
Program.cs(109,21): warning CA2200: Re-throwing caught exception changes stack information
Sometimes you need to throw the exception somewhere other than where you caught it, even on another thread. That is what ExceptionDispatchInfo is for:
public static void CaptureAndThrowLater()
{
ExceptionDispatchInfo? captured = null;
Thread worker = new(() =>
{
try
{
LoadPrices();
}
catch (Exception ex)
{
captured = ExceptionDispatchInfo.Capture(ex);
}
});
worker.Start();
worker.Join();
// thrown on the main thread with the original stack trace preserved
captured?.Throw();
}
System.FormatException: The input string '12,5O' was not in a correct format.
at System.Number.ThrowFormatException[TChar](ReadOnlySpan`1 value)
at System.Number.ParseDecimal[TChar](ReadOnlySpan`1 value, NumberStyles styles, NumberFormatInfo info)
at Bookstore.Errors.Demos.ParsePrice(String raw) in /src/errors/Program.cs:line 118
at Bookstore.Errors.Demos.LoadPrices() in /src/errors/Program.cs:line 115
at Bookstore.Errors.Demos.<>c__DisplayClass6_0.<CaptureAndThrowLater>b__0() in /src/errors/Program.cs:line 127
--- End of stack trace from previous location ---
at Bookstore.Errors.Demos.CaptureAndThrowLater() in /src/errors/Program.cs:line 136
at Program.<Main>$(String[] args) in /src/errors/Program.cs:line 26
Get to know the --- End of stack trace from previous location --- line: it means "everything above was recorded where the exception was caught, everything below is where it was thrown again". await uses the same mechanism internally, so you see this line often in async stack traces and it is not an error. Frames above it show where the error was born, frames below show who was waiting for it.
The First Exception Matters: Finding the Root in 51 Errors
Back to the log from Guess First. On startup the application loads its price catalog from a file; someone wrapped that in a try/catch "so the app still comes up":
PriceCatalog catalog = app.Services.GetRequiredService<PriceCatalog>();
try
{
catalog.Load("data/prices.csv");
}
catch (Exception ex)
{
// swallowed at startup: the app comes up but the catalog is empty
app.Logger.LogError(ex, "Price catalog warm-up failed");
}
app.MapGet("/api/books/{id:int}/price", (int id, PriceCatalog prices) => Results.Ok(new { id, price = prices.GetPrice(id) }));
PriceCatalog.GetPrice goes to the dictionary with _prices![bookId]. After sending 50 requests to the app, we look at the log with two commands:
$ grep -c "fail:" app.log
51
$ grep -oE "^ +System\.[A-Za-z.]+Exception" app.log | sort | uniq -c | sort -rn
50 System.NullReferenceException
1 System.IO.DirectoryNotFoundException
$ grep -m1 -A3 "fail:" app.log
11:55:55.293 fail: Bookstore.Api[0]
Price catalog warm-up failed
System.IO.DirectoryNotFoundException: Could not find a part of the path '/src/api/data/prices.csv'.
at Interop.ThrowExceptionForIoErrno(ErrorInfo errorInfo, String path, Boolean isDirError)
The 50 NullReferenceExceptions are symptoms; the DirectoryNotFoundException on the first line is the cause. The deployment didn't copy the CSV (Comma-Separated Values) file into the data/ folder. Adding a null check to GetPrice would turn 50 errors into 50 "catalog is empty" errors without solving anything. The rule is simple: when you see many errors at once, first find the earliest one in time order, then count the most frequent one. The frequent one tells you how big the impact is; the first one is often the cause. Building a timeline from logs is covered in detail in the eighth post of this series, "Finding the Cause in Logs: Correlation IDs, Trace IDs and Building a Timeline".
From the traceId in ProblemDetails to the Log
When a user or the frontend team comes to you saying "I got a 500", the only clue in their hands is the response itself. Here is the real response of the same endpoint in an ASP.NET Core 10 app with AddProblemDetails and UseExceptionHandler enabled:
$ curl -s -i http://localhost:5201/api/books/12/price
HTTP/1.1 500 Internal Server Error
Content-Type: application/problem+json
{"type":"https://tools.ietf.org/html/rfc9110#section-15.6.1","title":"An error occurred while processing your request.","status":500,"traceId":"00-b336c36b1f3f1102b0216db006c36934-82d5ef02e74e2ce6-00"}
There is no exception message in the response, and there shouldn't be one in production. But there is a traceId, in W3C (World Wide Web Consortium) Trace Context format: 00 is the version, followed by the 32-character trace ID, the 16-character span ID and the flags. If IncludeScopes is on in the console logger, the same trace ID shows up in the scope of the log line:
11:55:45.111 fail: Microsoft.AspNetCore.Diagnostics.ExceptionHandlerMiddleware[1]
=> SpanId:82d5ef02e74e2ce6, TraceId:b336c36b1f3f1102b0216db006c36934, ParentId:0000000000000000 => ConnectionId:0HNPTEO9M82VJ => RequestPath:/api/books/12/price RequestId:0HNPTEO9M82VJ:00000001
An unhandled exception has occurred while executing the request.
System.NullReferenceException: Object reference not set to an instance of an object.
at Bookstore.Api.PriceCatalog.GetPrice(Int32 bookId) in /src/api/Program.cs:line 42
at Program.<>c.<<Main>$>b__0_1(Int32 id, PriceCatalog prices) in /src/api/Program.cs:line 27
at lambda_method1(Closure, Object, HttpContext)
at Microsoft.AspNetCore.Diagnostics.ExceptionHandlerMiddlewareImpl.Invoke(HttpContext context)
The b336c36b… in the response and TraceId:b336c36b… in the log are the same. Search for that value in your log tool and you find every line of that request, including logs from other services if the request spread to them. That's why telling the support team "ask for the traceId in the response, not a screenshot" cuts debugging time from minutes to seconds. Setting up global error handling is covered in the ASP.NET Core Web API series post "Global Error Handling in ASP.NET Core: Exception Middleware and ProblemDetails", and producing the logs in "Logging in ASP.NET Core: ILogger, Log Levels, Structured Logging and Serilog".
The Frontend Side: JavaScript Stack Traces and Source Maps
JavaScript in production is a minified, single-line bundle. Here is a small TypeScript file that computes the cart total:
interface CartLine {
bookId: number;
quantity: number;
unitPrice?: { amount: number };
}
function lineTotal(line: CartLine): number {
return line.quantity * line.unitPrice!.amount;
}
export function cartTotal(lines: CartLine[]): number {
return lines.reduce((sum, line) => sum + lineTotal(line), 0);
}
console.log(cartTotal([
{ bookId: 7, quantity: 2, unitPrice: { amount: 349.9 } },
{ bookId: 12, quantity: 1 },
]));
Built with esbuild using --minify --sourcemap and run on Node.js 24, it produces this stack:
TypeError: Cannot read properties of undefined (reading 'amount')
at q (/app/dist/main.js:1:509)
at /app/dist/main.js:1:555
at Array.reduce (<anonymous>)
at i (/app/dist/main.js:1:539)
at Object.<anonymous> (/app/dist/main.js:1:575)
Function names are q and i, and everything is on line 1. Run the same file with node --enable-source-maps and the source map kicks in:
TypeError: Cannot read properties of undefined (reading 'amount')
at lineTotal (/app/cart.ts:8:42)
at <anonymous> (/app/cart.ts:12:44)
at Array.reduce (<anonymous>)
at cartTotal (/app/cart.ts:12:16)
at Object.<anonymous> (/app/cart.ts:15:13)
Now the error is on line 8 of cart.ts: the non-null assertion in unitPrice! lied. In the browser, DevTools applies source maps automatically as long as it can reach the .map file. If you don't want to expose source maps publicly in production, a common approach is to upload them to your error tracking tool and remove them from the server; the details of the Sources panel are in the upcoming Browser DevTools series. Building error chains in JavaScript (cause) is covered in the JavaScript intermediate series post "JavaScript Error Handling: try/catch, Custom Error Classes and Error Chains with cause".
Angular's NG Error Codes
Angular errors start with NG and a four-digit code; every code has an explanation page under angular.dev/errors, and the "Find more at" link at the end of the message takes you straight there. We produced three common ones for real with Vitest tests on Angular 22.2:
@Component({
selector: 'app-cart-badge',
template: `<span>{{ itemCount }} items</span>`,
})
class CartBadge implements AfterViewInit {
itemCount = 0;
ngAfterViewInit(): void {
this.itemCount = 3;
}
}
@Injectable({ providedIn: 'root' })
class CartService {
private readonly pricing = inject(PricingService);
}
@Injectable({ providedIn: 'root' })
class PricingService {
private readonly cart = inject(CartService);
}
@Injectable()
class OrderApi {}
FAIL |ng| src/app/errors.spec.ts > Angular error codes > NG0100 (exhaustive check)
Error: NG0100: ExpressionChangedAfterItHasBeenCheckedError: Expression has changed after it was checked. Previous value: '0'. Current value: '3'. Expression location: _CartBadge component. Find more at https://v22.angular.dev/errors/NG0100
FAIL |ng| src/app/errors.spec.ts > Angular error codes > NG0200
Error: NG0200: Circular dependency detected for `_CartService`. Source: DynamicTestModule. Path: _CartService -> _PricingService -> _CartService. Find more at https://v22.angular.dev/errors/NG0200
FAIL |ng| src/app/errors.spec.ts > Angular error codes > NG0201
ɵNotFound: NG0201: No provider found for `_OrderApi`. Source: DynamicTestModule. Find more at https://v22.angular.dev/errors/NG0201
NG0100 means a value changed after change detection had checked it. The message gives you the previous and current values ('0' → '3') and the component it happened in, and Vitest points at the expression in the template. This check only runs in development mode. There is a surprise here: with the default settings the same component threw no error at all; the screen silently stayed at 0 items. In Angular 22 components are OnPush by default and the check pass is not exhaustive; to see the error we added provideCheckNoChangesConfig({ exhaustive: true }) to the test. So in Angular 22 the symptom of this bug is often not an error but a screen that doesn't update. Bug hunts around signals and change detection are the subject of the seventh post in this series.
NG0200 is a circular dependency in DI (Dependency Injection); the Path part is the cycle itself. NG0201 is what used to be called NullInjectorError: there is no provider for the requested token. In Angular 22 the message starts with "No provider found for", so searches for the old "No provider for HttpClient!" may no longer match exactly. By the way, since HttpClient is now provided in root by default, our test that did inject(HttpClient) without provideHttpClient() passed without an error. The leading underscore in class names (_CartService) is a compiler-generated name; drop it when you search your code.
In Production
When you log an exception, pass the exception itself, not its message: LogError(ex, "..."). If you log only ex.Message, the inner exception chain and the stack trace are lost; the 503 above shrinks to the sentence "Payment for order 1042 failed."
Include PDB files in the deployment or keep them on a symbol server. With a stack trace that has no line numbers, you can only guess which line was null. The same applies to source maps on the frontend: archive the .map files the build produces together with the release, or you won't be able to solve a bug two weeks later.
Watch the log volume. One cause produced 50 symptoms; with real traffic that becomes 50,000, and log cost, or even the log pipeline itself, becomes a problem. An error tracking tool that groups repeated errors, or the uniq -c idea in a log query (grouping by type and message), should be part of your first look.
Don't return exception messages or stack traces to the client in production responses. The traceId in ProblemDetails is enough; everything else is in the log. Stack traces can contain file paths, class names and sometimes connection details.
Common Pitfalls
The catch that swallows exceptions. catch (Exception) { } or a bare return null; doesn't make the error go away; it moves it: the problem shows up on a later line as a NullReferenceException, far from the real cause. The warm-up error in this post is exactly that: logged but swallowed, and it produced 50 misleading errors. If the app cannot work without the data, failing at startup is more honest.
Pasting the message into a search engine without reading it. Often the single thing the message tells you is enough: in The input string '12,5O' the last character is not the digit zero, it is a capital letter O. Pasted into a search, that detail is lost among generic results.
Reading only the top line. PaymentFailedException is context; the HttpRequestException on the line starting with ---> is the cause. Don't open a ticket before reaching the innermost exception of the chain.
Writing throw ex; and ignoring the CA2200 warning. Treating analyzer warnings as errors in the build closes this trap completely.
Getting lost in framework frames. Frames such as System.Number and Microsoft.AspNetCore add noise to a stack trace; the first frame in your own namespace (Bookstore.*) is usually where you should look.
A Small Experiment
★☆☆ In the catch block of CheckoutService, throw new PaymentFailedException(...) without passing ex as the inner exception. What disappears from the ToString() output? How could someone looking at the log find out about the 503?
★★☆ In WhenAllAwaitAsync, give the two tasks different Task.Delay durations (10 and 50 ms). Which exception lands in the catch? Then add a line that logs all.Exception and verify that both errors appear in the log.
★★★ In the minimal API, instead of swallowing the warm-up error, prevent the app from starting and add a health check endpoint. What happens if the file is missing during deployment, and how many error lines are produced? Analyze the same log with grep and compare it with the earlier 51 lines.
We can read error messages and stack traces now; most of the time that's enough to tell you where the error is, but not why it happens. In the next post we move to the debugger and look for the "why" inside the running program with conditional breakpoints, tracepoints, data breakpoints and Exception Settings.