Medium
Read writing from C:\Dave\Storey on Medium. Software engineer & lover of all things code. Too much to learn and so little time. Currently working at Trainline London.
truestorydavestorey.medium.comRead writing from C:\Dave\Storey on Medium. Software engineer & lover of all things code. Too much to learn and so little time. Currently working at Trainline London.
truestorydavestorey.medium.com
int count = 42; // Allocated on the stack (no GC involvement) object boxed = count; // BOXING: 'count' is wrapped & allocated on the Managed Heap! int unboxed = (int)boxed; // UNBOXING: Value is copied back out to the stackint count = 42; // Allocated on the stack (no GC involvement) ⚠️ Warning: Frequent boxing creates hidden allocation churn on the heap, triggering unexpected Gen 0 collections and making it one of the primary targets for performance optimisation. The concepts of boxing and unboxing used to be very common interview questions back in the early 2000s, but they have almost completely disappeared these days. 🤷 ### Fundamentals: The Managed Heap and Generations When a .NET application starts, the runtime reserves a contiguous region of memory known as the managed heap. Every allocation of a reference type resides here. To optimize memory management, the GC categorises objects into distinct generations based on the generational hypothesis: the empirical observation that most objects die shortly after creation. #### Small Object Heap (SOH) Generations - Generation 0 (Gen 0): The youngest generation containing short-lived objects (e.g., local variables, temporary strings, iterators). Collections occur frequently, take a few milliseconds, and aim to reclaim memory rapidly. - Generation 1 (Gen 1): Serves as a buffer between short-lived and long-lived objects. Objects surviving a Gen 0 collection are promoted here. - Generation 2 (Gen 2): Contains long-lived objects (e.g., static data, application singletons, long-running caches). A Gen 2 collection is a full collection, evaluating the entire heap, which makes it the most expensive operation. ℹ️ Note: All objects that get created by the runtime start off in Gen 0. They then progress through the different generations with each pass of the collector. The longer the object “lives”, the further up the generations it gets and the more costly it gets to clean up. #### Specialised Heap Segments - Large Object Heap (LOH): Objects larger than 85,000 bytes bypass Gen 0 and are allocated directly on the LOH. Historically, the LOH was swept rather than compacted to reduce CPU overhead, leading to potential fragmentation. - Pinned Object Heap (POH): Introduced to isolate pinned objects (e.g., buffers passed to unmanaged code via interop). This prevents heap fragmentation on the small object heaps caused by fixed memory addresses. - Frozen Object Heap (FOH): Dedicated exclusively to immutable, read-only objects (such as string literals or objects created at compile/startup time that never change). The GC never scans, marks, or moves objects on the FOH during a collection pass. This eliminates GC overhead entirely for these objects and improves CPU cache efficiency. ### Anatomy of a Garbage Collection Pass When an allocation request exceeds available space in Gen 0 or memory pressure triggers a collection, the GC executes a multi-phase workflow: 1. Marking Phase: The GC constructs an object graph starting from “roots” (stack frames, CPU registers, static references, GC handles). It walks all reachable pointers and marks those objects as live. If the collector finds no reachable pointers, then the objects are marked as dead. 2. Plan Phase: The GC evaluates the level of heap fragmentation across generations to decide between compaction (moving objects together) or sweeping (reclaiming dead space in place). 3. Relocate Phase: If compacting, pointer references across the entire application are updated to point to the proposed new memory locations. Ok, so the GC has done a pass and marked, planned and relocated, great! But in step 2 we mentioned that the GC will make a decision about compaction or sweeping. Lets take a deeper look at those now. #### Compact Phase (Default for Small Object Heap) - How it works: Live objects are relocated so they sit contiguously in memory. Pointers/references are updated to reflect the new memory locations, and the remaining space is left as one large, contiguous free block. - Why: Eliminates memory fragmentation and allows future allocations to be done via a fast pointer-bump. #### Sweep Phase (Default for Large Object Heap / Generational Sweeping) - How it works: Live objects are left exactly where they are in memory. The GC scans the dead spaces and links them into a “free list” so that future allocations of similar size can reuse those gaps. - Why: Avoids the expensive performance cost of copying large memory blocks around. Here is a helpful image to summarise the above steps: The .NET Garbage collector cycle. Note: image generated by AI and the LOH deliberately ommited ### Memory Leaks — Patterns and Diagnostics As discussed above, the garbage collection mechanisms in .NET are exceptionally powerful. The traversal of object graphs during the sweep phase allows the collector to identify objects that are no longer needed easily, freeing up memory. So, if the collector is so smart, why do we still hear about memory leaks and watch applications crash after running out of memory? Unfortunately, even with the best GC mechanisms in the world, poor application logic can cause references to remain accidentally reachable. This prevents the GC from reclaiming that memory. As these uncollected objects accumulate, overall memory consumption continues to rise. #### Typical Pitfalls - Captive Dependencies in Middleware: Injecting scoped services (e.g., DbContext) into a middleware's constructor. Since middleware instances are instantiated as singletons, the scoped object is trapped for the lifetime of the process, promoting short-lived request objects straight to Generation 2. - Unsubscribed Events: Long-lived publishers hold implicit references to subscriber instances via delegate targets. - Static Caches Without Eviction: Static collections grow indefinitely over the lifecycle of the process. - Captured Variables in Lambda Expressions: Closures keeping parent instances alive longer than expected. - Excessive Object Pinning: Holding pointers pinned via GCHandleType.Pinned across collections, which blocks memory compaction. #### Diagnostic Tooling Luckily the .NET ecosystem provides us with many useful tools to help diagnose memory issues: - dotnet-gcdump: Lightweight tool for capturing heap snapshots in production without noticeable performance degradation. - dotnet-dump: Captures full process dumps for deep analysis of object graphs, memory retention paths, and unmanaged memory usage. - PerfView & JetBrains dotMemory: Advanced profile tools for investigating allocation flame graphs and tracing memory growth over time. ℹ️ Note: I will not go into too much depth here, as these are powerful tools that deserve their own dedicated article. However, I shall return to them in a future post, where I plan to explore detecting common memory leaks in .NET applications in greater detail. ### When GC Goes Wrong — The impact of Frequent GC Runs (GC Thrashing) So by now, you are probably thinking, “Wow, the GC is really cool. I am so glad we do not have to worry about cleaning up everything we allocate!” And you would be right. However, this blog post was not written to brag about how impressive the GC is; it is here to highlight what it is, how it works, and why you need to understand it so you can resolve issues when things go wrong. Yes, the GC operates automatically by running collection passes, but there is an inherent danger here. If you allocate too many small objects too frequently, the free memory in Gen 0 gets consumed quickly. This forces the collector to run too often, which can cause severe performance degradation such as: - Latency Spikes: Non-background collections require Stop-the-World (STW) execution pauses. A STW basically occurs when the .NET CLR needs to pause all threads to safely perform a complex memory managmenet task. Frequent collection passes freeze application execution threads, causing noticeable tail-latency spikes in web APIs. - CPU Starvation: Object graph traversal, pointer relocation, and memory compaction are CPU-intensive operations. High GC activity consumes CPU cycles that should belong to business logic, driving overall server CPU usage toward 100%. - Thread Pool Starvation: As execution threads stall waiting for GC memory allocations to complete, incoming request queues overflow. The thread pool exhausts available threads, leading to HTTP request timeouts and 503 errors under high load. - The Promotion Spiral: When Gen 0 runs continuously under high allocation rates, short-lived objects don’t have time to die before a collection pass. They get promoted to Gen 1 and Gen 2, forcing expensive full collections far more frequently. 🪧 Random note: If you are interested in some of the more complex elements of why STW events still are a thing in modern .NET compared to other languages like GO, then I would highly recommend reading the discussions on Github here and here as they both give great insight into the GC design and its future. ### Writing GC-Friendly Code To mitigate the issues highlighted in the previous section, we need to write code in a way that makes life easier for the GC. Reducing the allocation rate is the most effective way to lower GC pressure and eliminate CPU spikes. To achieve this, you can adopt better coding practices such as: #### 1. Renting Buffers with ArrayPool<T> Allocating large or short-lived arrays inside frequently executed methods causes heavy Generation 0 churn or forces allocations onto the Large Object Heap (LOH). Renting a buffer allows memory reuse across calls. using System.Buffers; // BAD: Allocates a new byte array on every execution pass public async Task ProcessDataStreamAsync(Stream stream) { byte[] buffer = new byte[8192]; // Allocated on heap every method call int bytesRead = await stream.ReadAsync(buffer); // Process buffer... } // GOOD: Rents a shared buffer from the pool and returns it when finished public async Task ProcessDataStreamOptimisedAsync(Stream stream) { // Rent a buffer of at least 8192 bytes byte[] buffer = ArrayPool<byte>.Shared.Rent(8192); try { int bytesRead = await stream.ReadAsync(buffer); // Process buffer using the bytesRead slice... } finally { // Return the buffer so it can be reused by future callers ArrayPool<byte>.Shared.Return(buffer); } } #### 2. Zero-Allocation String Operations (ReadOnlySpan<char> and string.Create) Standard string operations like Substring or string interpolation allocate new string objects. Using ReadOnlySpan<char> slices text in place, while string.Create writes formatted strings directly into pre-allocated memory. // BAD: Allocates multiple intermediate strings on the heap public string FormatOrderSummary(string prefix, int orderId, string suffix) { // String interpolation creates multiple internal allocations return $"{prefix.Trim()}-{orderId:D6}-{suffix.Trim()}"; } // GOOD: Uses Span slicing and string.Create for zero intermediate allocations public string FormatOrderSummaryOptimised(string prefix, int orderId, string suffix) { ReadOnlySpan<char> prefixSpan = prefix.AsSpan().Trim(); ReadOnlySpan<char> suffixSpan = suffix.AsSpan().Trim(); int totalLength = prefixSpan.Length + 1 + 6 + 1 + suffixSpan.Length; // Fills the string buffer directly in place without intermediate allocations return string.Create(totalLength, (orderId, prefix, suffix), (span, state) => { int position = 0; state.prefix.AsSpan().Trim().CopyTo(span); position += state.prefix.AsSpan().Trim().Length; span[position++] = '-'; state.orderId.TryFormat(span.Slice(position, 6), out _, "D6"); position += 6; span[position++] = '-'; state.suffix.AsSpan().Trim().CopyTo(span[position..]); }); } #### 3. Avoiding Async State Machine Allocations with ValueTask Returning Task<T> from asynchronous methods always allocates a Task object on the heap unless the runtime can use a cached task. When a method completes synchronously most of the time, ValueTask<T> eliminates this allocation entirely. private readonly Dictionary<string, User> _cache = new(); // BAD: Always allocates a Task<User> on the heap, even when returning from cache public async Task<User> GetUserAsync(string userId) { if (_cache.TryGetValue(userId, out var user)) { return Task.FromResult(user); // Allocates a Task object on the heap } return await FetchUserFromDatabaseAsync(userId); } // GOOD: Zero allocations when completing synchronously from memory public async ValueTask<User> GetUserOptimisedAsync(string userId) { if (_cache.TryGetValue(userId, out var user)) { return user; // Wraps struct on stack, zero heap allocation } return await FetchUserFromDatabaseAsync(userId); } private async Task<User> FetchUserFromDatabaseAsync(string userId) { // Database access logic... await Task.Delay(10); return new User(); } #### 4. Struct Wrappers and ref readonly Parameters Passing large structs into methods by value copies the entire struct payload on the stack. Using the in or ref readonly keyword passes a read-only reference to the value type, avoiding unnecessary stack copying while preventing accidental modification. public readonly struct ProcessingMetrics { public readonly long BytesProcessed; public readonly long ErrorCount; public readonly TimeSpan Duration; public readonly Guid BatchId; } // BAD: Copies the full struct payload onto the stack frame on every call public void LogMetrics(ProcessingMetrics metrics) { Console.WriteLine($"Batch {metrics.BatchId} processed {metrics.BytesProcessed} bytes."); } // GOOD: Passes a read-only pointer reference to the existing struct on the stack public void LogMetricsOptimised(in ProcessingMetrics metrics) { Console.WriteLine($"Batch {metrics.BatchId} processed {metrics.BytesProcessed} bytes."); } #### 5. Enforcing Max Size and Entry Sizing when using IMemoryCache Without a global SizeLimit and explicit entry sizing, IMemoryCache grows without bound on the heap. Setting .SetSize() on each entry is essential for the cache eviction engine to work. using Microsoft.Extensions.Caching.Memory; // 1. Configure MemoryCache with a global capacity limit (e.g., max 1000 units) var cacheOptions = new MemoryCacheOptions { SizeLimit = 1000 // Arbitrary total capacity units }; var cache = new MemoryCache(cacheOptions); // BAD: Missing .SetSize() causes SizeLimit to be completely ignored! cache.Set("user_123", new UserData()); // GOOD: Entry specifies its relative size so compaction can evict items properly var entryOptions = new MemoryCacheEntryOptions() .SetSize(1) // Occupies 1 unit toward the 1000-unit SizeLimit .SetSlidingExpiration(TimeSpan.FromMinutes(5)); cache.Set("user_123", new UserData(), entryOptions); #### 6. Avoiding Excessive LINQ Chaining in Hot Paths LINQ method chains allocate enumerator state machines, delegate instances, and intermediate collections on every call. In high-frequency paths, plain loops or Span<T> eliminate heap allocations entirely. // BAD: Allocates multiple iterators, delegates, and intermediate lists on the heap public int SumActiveUserScoresLINQ(IEnumerable<User> users) { return users .Where(u => u.IsActive) // Allocates WhereEnumerableIterator + Func delegate .Select(u => u.Score) // Allocates SelectEnumerableIterator + Func delegate .ToList() // Allocates List<int> on the heap .Sum(); } // GOOD: Zero heap allocations when iterating over arrays or spans public int SumActiveUserScoresOptimised(ReadOnlySpan<User> users) { int total = 0; foreach (ref readonly var user in users) { if (user.IsActive) { total += user.Score; } } return total; } #### 7. Implementing IDisposable Correctly Standardizing resource cleanup ensures unmanaged handles are freed promptly and removes instances from the GC’s finalization queue via GC.SuppressFinalize. public class SafeResourceHolder : IDisposable { private IntPtr _unmanagedResource; // Unmanaged resource handle private SemaphoreSlim _lock; // Managed resource handle private bool _disposed; public SafeResourceHolder() { _unmanagedResource = /* allocate unmanaged handle */; _lock = new SemaphoreSlim(0, 3); } // StandardDispose pattern public void Dispose() { Dispose(disposing: true); // Tells GC that the finaliser does not need to run, saving a collection pass GC.SuppressFinalize(this); } protected virtual void Dispose(bool disposing) { if (_disposed) return; if (disposing) { // Free managed resources here if any exist _lock.Dispose(true); } // Free unmanaged resources if (_unmanagedResource != IntPtr.Zero) { // Free unmanaged handle... _unmanagedResource = IntPtr.Zero; } _disposed = true; } // Finaliser: Only runs as a fallback if Dispose() was never called ~SafeResourceHolder() { Dispose(disposing: false); } } ### What’s New in .NET 10? .NET 10 brings critical refinements to memory management, optimizing GC behaviour automatically for containerized environments and modern architectures. #### DATAS Enabled by Default: Elastic Container Memory Dynamic Adaptation To Application Sizes (DATAS) was introduced as an opt-in feature in .NET 8, but .NET 10 makes it the default behavior for Server GC. - The Problem It Solves: Traditional Server GC scales heap size aggressively based on available system memory rather than active application demand. In Kubernetes clusters or containerized environments, this caused idle or low-traffic pods to hold onto massive heaps, triggering unnecessary cloud memory costs or Out-Of-Memory (OOM) kills by orchestrators. - How DATAS Works: DATAS dynamically monitors application throughput and active live-data size. During light traffic, it aggressively shrinks heap reservation and returns unneeded memory pages back to the operating system. During traffic bursts, it scales the heap allocation limit seamlessly — ensuring low memory footprints without manual GCHeapHardLimit tuning. - Deep dive: Preparing for the .NET 10 GC (DATAS) — .NET Blog #### JIT-Driven Stack Allocation & Advanced Escape Analysis In .NET 10, the JIT compiler and the GC work in tandem through improved Escape Analysis to eliminate heap allocations entirely before C# code even executes. - Non-Escaping Lambda Closures: When a lambda expression or delegate does not escape its enclosing method scope (for instance, passing a predicate to a local inline method), the JIT allocates the closure environment directly on the stack rather than on Generation 0. - Stack-Allocated Value-Type Arrays: Small fixed-size arrays of primitive or value types created within local method scopes are automatically transformed by the JIT into stackalloc structures when the compiler proves the reference cannot escape the call frame. - Deep dive: What’s new in .NET 10 runtime | Microsoft Learn public class EscapeAnalysisDemos { // 1. Stack-Allocated Value-Type Arrays // In .NET 10, the JIT proves 'data' does not escape local execution. // It stack-allocates the buffer automatically: zero Gen 0 allocation! public int ProcessLocalData() { int[] data = new int[] { 10, 20, 30, 40 }; // No heap allocation in .NET 10 int sum = 0; foreach (var val in data) sum += val; return sum; } // 2. Non-Escaping Lambda Closures // Capturing local variables inside a closure historically created a display class on the heap. // In .NET 10, because 'predicate' is consumed locally and does not escape, // the closure environment is allocated directly on the stack frame. public int FilterAndCalculate() { int threshold = 25; ReadOnlySpan<int> numbers = stackalloc int[] { 10, 20, 30, 40, 50 }; // The captured 'threshold' variable is placed in a stack-allocated closure scope return LocalSum(numbers, num => num > threshold); // Local helper accepting the delegate pass-through static int LocalSum(ReadOnlySpan<int> values, Func<int, bool> predicate) { int total = 0; foreach (var v in values) { if (predicate(v)) total += v; } return total; } } } ### Dynamic ARM64 Write-Barrier Selection (WriteBarrierManager) With cloud infrastructure shifting heavily toward ARM64 processors (such as AWS Graviton and Apple Silicon), .NET 10 optimizes hardware-level memory instruction paths via a new runtime subsystem: WriteBarrierManager. - Hardware-Specific JIT Patching: Rather than using generic assembly routines for write barriers, the runtime queries CPU cache line sizes and memory ordering rules at startup. - Targeted Assembly Variants: It dynamically patches the execution code with hyper-optimized ARM64 assembly variants tailored specifically to the host architecture, reducing CPU cache miss rates and lowering total GC scanning overhead across multi-threaded applications. - Deep dive: Smarter Write Barriers For Arm64 In .NET CoreCLR ### Final Thoughts Automatic memory management frees us to focus on solving business problems, but high-throughput systems require a developer who understands the rules of the road. From the basic mechanics of stack vs heap allocations to the zero-allocation JIT escape analysis in .NET 10, knowing how the GC behaves empowers you to write C# that works with the runtime, not against it. Profiling allocation counts today is the easiest way to prevent p99 latency spikes in production tomorrow. Thank you all for taking the time today to read my blog 🙏 ### Further reading - Fundamentals of garbage collection — .NET | Microsoft Learn - Workstation vs. server garbage collection (GC) — .NET | Microsoft Learn - Dynamic adaptation to application sizes (DATAS) — .NET | Microsoft Learn - Garbage Collection and Performance — .NET | Microsoft Learn - Preparing for the .NET 10 GC (DATAS) — .NET Blog - What’s new in .NET 11 | Microsoft Learn - The Garbage Collection Handbook// Homegrown middleware: Fragile, manual stream handling public class CustomHttpLoggingMiddleware { private readonly RequestDelegate _next; private readonly ILogger<CustomHttpLoggingMiddleware> _logger; public CustomHttpLoggingMiddleware(RequestDelegate next, ILogger<CustomHttpLoggingMiddleware> logger) { _next = next; _logger = logger; } public async Task InvokeAsync(HttpContext context) { // Must manually enable rewindable streams context.Request.EnableBuffering(); using var reader = new StreamReader(context.Request.Body, leaveOpen: true); var requestBody = await reader.ReadToEndAsync(); // Forget this and model binding breaks downstream! context.Request.Body.Position = 0; _logger.LogInformation("Incoming Request: {Body}", requestBody); // Swap the response stream to intercept outbound payload var originalResponseBody = context.Response.Body; using var responseStream = new MemoryStream(); context.Response.Body = responseStream; // invoke the next middleware chain delegate await _next(context); // now we are in the response cycle responseStream.Position = 0; var responseBody = await new StreamReader(responseStream).ReadToEndAsync(); _logger.LogInformation("Outgoing Response: {Body}", responseBody); responseStream.Position = 0; await responseStream.CopyToAsync(originalResponseBody); } } #### ❓ Why custom middleware falls short: - Stream Position Bugs: Forgetting Position = 0 or omitting EnableBuffering() silently breaks model binding or downstream middleware. - Performance Overhead: Allocating MemoryStream buffers for every request causes memory pressure under load. - Lack of Guardrails: Without body size limits or header redaction built in, large file uploads or bearer tokens can easily spill into your logging sinks. - Support Overhead: This code will need maintaining through multiple .NET SDK versions as well as potential extension to log more things in future. #### The outgoing HttpClient request logging scenario: On the client side (outgoing requests), developers often fall into a similar trap — wrapping every API call in a try-catch block simply to log diagnostic data: // Anti-pattern: Cluttering business logic with HTTP details // and unnecessarily materialising the response as a string. public async Task<UserDto?> GetUserAsync(int id) { try { var response = await _httpClient.GetAsync($"/users/{id}"); var content = await response.Content.ReadAsStringAsync(); // Anti-pattern: Logging the entire response body as a string _logger.LogInformation("Received response: {Content}", content); response.EnsureSuccessStatusCode(); // Anti-pattern: Deserialising from an intermediate string return JsonSerializer.Deserialize<UserDto>(content); } catch (HttpRequestException ex) { // Anti-pattern: Exception handling used purely for diagnostic side-effects _logger.LogError(ex, "HTTP call failed for user {Id}", id); throw; } } #### ❓ Why this is poor practice: - Pollutes Core Logic: Business operations become obscured by repetitive diagnostic code. - Poor Performance: The response body is read twice — first by ReadAsStringAsync and then again when the deserialisation method is invoked. - Inconsistent Diagnostics: Manual logging leads to mismatched log formats, forgotten endpoints, and accidental leaks of sensitive payload data. - Misaligned Responsibilities: Exceptions should manage exceptional control flow, not serve as hooks for standard HTTP telemetry. - Incomplete Error Handling: This approach fails to catch cancellation scenarios/timeouts (such as a TaskCanceledException). 💡 For more tips about how to handle HttpClient timeout scenarios effectively. Please take a look at my blog post Handling Timeouts in C# like a pro ### Best practice: Server-Side incoming request logging using Native ASP.NET Middleware Many engineers do not realise that Microsoft has already built a highly configurable HTTP logging middleware into ASP.NET Core. It handles request and response logging centrally before your controllers or endpoints ever execute. This middleware is included out of the box in the Microsoft.AspNetCore.App framework, so it is automatically available in any Web SDK project (<Project Sdk="Microsoft.NET.Sdk.Web">). While this middleware is comprehensively documented in Microsoft’s HTTP logging in .NET and ASP.NET Core guide, here is a quick overview of how to set it up: #### Configuring middleware When adding the middleware to your service collection, you can configure what should be added to your logs via simple action lambda: builder.Services.AddHttpLogging(logging => { // Specify exact fields to capture logging.LoggingFields = HttpLoggingFields.RequestMethod | HttpLoggingFields.RequestPath | HttpLoggingFields.ResponseStatusCode | HttpLoggingFields.ResponseBody; logging.RequestHeaders.Add("Authorization"); logging.ResponseHeaders.Add("SomeResponseHeader"); // Select which encoding to use for a specific media type logging.MediaTypeOptions.AddText("application/json"); // Set buffer limits logging.RequestBodyLogLimit = 4096; logging.ResponseBodyLogLimit = 4096; // configures the middleware to consolidate all of its enabled logs // for a request and response into one log at the end logging.CombineLogs = true; }); // register with app builder var app = builder.Build(); // Place early in the pipeline to capture incoming requests cleanly app.UseHttpLogging(); ℹ️ Note: By default all request/response headers are redacted from the logs for security purposes. You can override this behaviour as shown above by adding to the RequestHeaders and ResponseHeaders collection to explicitly define the headers you want to appear in your logs unredacted. #### Configuring the log level As with all logging in the .NET ecosystem, log levels are fully configurable via your appsettings.json configuration. HttpLoggingMiddleware is no different and can be configured by setting an override for the Microsoft.AspNetCore.HttpLogging.HttpLoggingMiddleware namespace to Information : { "Logging": { "LogLevel": { "Default": "Warning", "Microsoft.AspNetCore": "Warning", "Microsoft.AspNetCore.HttpLogging.HttpLoggingMiddleware": "Information" } } } #### Redacting sensitive information One of the biggest benefits of this middleware is that Microsoft has already taken into account the sensitive nature of some of your request/response data. They allow you to easily customise how the redaction works via configuration too: // Use your classifications during service registration builder.Services.AddRedaction(); builder.Services.AddHttpLoggingRedaction(op => { // Configure if path parameters should be redacted op.RequestPathParameterRedactionMode = HttpRouteParameterRedactionMode.None; // Determines how the request path is logged op.RequestPathLoggingMode = IncomingPathLoggingMode.Formatted; // Setup data classes using classifications for request/response headers // and route parameters op.RequestHeadersDataClasses.Add(HeaderNames.Accept, DataClassification.Public); op.ResponseHeadersDataClasses.Add(HeaderNames.ContentType, DataClassification.Private); op.RouteParameterDataClasses = new Dictionary<string, DataClassification> { { "param-one", DataClassification.Personal }, }; // Add the paths that should be filtered, with a leading '/'. op.ExcludePathStartsWith.Add("/home"); // Allow logging of unmatched path routes rather than Unknown op.IncludeUnmatchedRoutes = true; }); ℹ️ Note: This redaction functionality is built on top of the Microsoft.Extensions.Compliance.Redaction nuget package so if you are unfamiliar with this I would highly recommend reading up on MSDN or this awesome blog post by Andrew Lock ### Best Practice: Client Side HttpClient logging using Microsoft.Extensions.Http.Diagnostics For outbound HTTP requests made via IHttpClientFactory, Microsoft provides a dedicated, highly optimized package called Microsoft.Extensions.Http.Diagnostics. dotnet package add Microsoft.Extensions.Http.Diagnostics This package means that instead of manually writing custom delegating handlers, logging scopes or cluttering service calls with bloated try..catch statements we can simply delegate the responsibility off to .NET Extended Diagnostics and IHttpClientFactory. ℹ️ Note: This extension is comprehensively documented here: HTTP client logging in .NET but again I think its worth doing a quick overview here to help you understand the high level usage. #### Enabling logging for all HttpClients clients At this point I’m going to assume you are familiar with the concept of the HttpClientBuilder pattern in .NET and the concept of typed clients but if you are new to .NET I would highly recommend you read up on MSND best practices. So to setup HttpClient logging is super simple: HostApplicationBuilder builder = Host.CreateApplicationBuilder(args); // Register redaction services (required) builder.Services.AddRedaction(); // Add enhanced HTTP client logging to all clients builder.Services.AddExtendedHttpClientLogging(); // Apply to a named client builder.Services.AddHttpClient("MyNamedClient") .AddExtendedHttpClientLogging(); // Apply to a typed client builder.Services.AddHttpClient<MyApiClient>() .AddExtendedHttpClientLogging(); // Add your HTTP clients builder.Services.AddHttpClient<MyApiClient>(); And that is it, you can add logging to all clients, a single typed client, or a named client. Simple right? ⚠️ Warning: You must register redaction services by calling AddRedaction from the Microsoft.Extensions.Compliance.Redaction package. The HTTP client logging feature redacts sensitive information by default (such as route parameters) and requires a redactor provider in the dependency injection container. #### Customising the logging configuration As with the logging middleware we discussed above, one of the core benefits if using these Microsoft packages is the extensive configuration options available to you; and the HttpClient logging is no different: builder.Services.AddExtendedHttpClientLogging(options => { // Log request headers with data classification options.RequestHeadersDataClasses.Add("User-Agent", DataClassification.None); options.RequestHeadersDataClasses.Add("Authorization", DataClassification.Unknown); // Log response headers options.ResponseHeadersDataClasses.Add("Content-Type", DataClassification.None); // Enable request/response body logging (use carefully in production) options.LogBody = true; // Limit request/response body reading to 4KB options.BodySizeLimit = 4096; // Ensure that application/json is considered a content type we want to log options.RequestBodyContentTypes.Add("application/json"); options.ResponseBodyContentTypes.Add("application/json"); }); ℹ️Note: I’ve gone through a few of the high level configuration options here. For more comprehensive details on configuration please consult the MSDN documentation here. Also remember that this can be configured per client or as a default for all clients! #### Configuring the log level The extended HttpClient logging functionality once again supports the standard appsettings.json log level overrides. However, this time you need to appreciate that it dynamically changes its log level depending on what happens with the request and response. The library logs at the following levels: - Information: When making a successful outgoing request, or when the response status code is outside the 400–599 range. - Error: When the outgoing request fails to complete due to a timeout, or when the response status code is within the 400–599 range. If you want to ensure that your Information-level logs are recorded correctly, you can add an override to your appsettings.json like so: { "Logging": { "LogLevel": { "Default": "Warning", "Microsoft.AspNetCore": "Warning", "Microsoft.Extensions.Http.Logging": "Information" } } } 💡 Tip: Because Microsoft.Extensions.Http.Logging automatically uses the Error level for failed requests and responses, you do not need to worry about excessive logging impact unless your upstream service suffers a substantial outage. This approach provides the diagnostics you need during failure scenarios without requiring you to permanently enable the Information-level override. ### Conclusion: Elevating Your .NET Diagnostics Clean diagnostic logging should never come at the expense of readable domain logic or performance. By stepping away from the habit of wrapping everything in try...catch blocks and abandoning fragile, custom stream manipulation, you unlock several immediate wins across your codebase: - Maintainable Service Layers: Your business logic remains focused on domain execution, returning results or letting genuine exceptions bubble up naturally. - Production-Safe Streaming: Native middleware and LoadIntoBufferAsync guard against memory leaks, stream exhaustion, duplicate request/response reading and broken deserialisation pipelines. - Built-in Security: Centralised header redaction and body size limits ensure sensitive credentials and massive file uploads never land in your logging sinks. - Architectural Consistency: Inbound and outbound HTTP telemetry follow structured, predictable formats across every service in your solution. #### Next Steps - Audit your codebase: Search for service methods containing try...catch blocks that simply log and re-throw HttpRequestException or HttpResponseException, then strip them out where possible. Remember: every line of code you delete is a line of code you do not have to maintain. - Leverage Microsoft Diagnostics: Use AddExtendedHttpClientLogging() when registering your IHttpClientFactory instances via the Microsoft.Extensions.Http.Diagnostics NuGet package. - Standardise server traffic: Replace custom server HTTP logging middleware with ASP.NET Core’s native app.UseHttpLogging(). - Reserve custom solutions for edge cases: Only resort to custom request and response logging logic — whether client or server side — when you need highly niche, status-code-specific behaviour. By pushing diagnostic logging out to the infrastructure boundary where it belongs, your code stays lean, your team stays productive, and your log sinks remain clean and action-oriented. Thank you for taking the time to read through my blog today 🙏 ### Additional reading: - Microsoft.Extensions.Http.Diagnostics Github repo — go look here for the source code to help you understand how the configuration works etc. - HTTP client logging in .NET — .NET | Microsoft Learn - HTTP logging in ASP.NET Core | Microsoft Learn - Logging in .NET and ASP.NET Core | Microsoft Learn - Data redaction in .NET — .NET | Microsoft Learn - HttpClient guidelines for .NET — .NET | Microsoft Learn