A practical guide to understanding and optimizing .NET application performance
Introduction
Performance isn't just about making things fast—it's about creating applications that scale, reduce cloud costs, and deliver exceptional user experiences. As a .NET developer, understanding performance fundamentals will set you apart in today's competitive landscape.
This book takes you through the essential performance concepts every .NET developer should master, from runtime optimizations to database query tuning. Each chapter builds on the previous one, giving you both the theory and practical skills to write high-performance .NET applications.
Chapter 1: Why Performance Matters in .NET Applications
Performance in .NET applications directly impacts three critical areas: user satisfaction, operational costs, and system scalability. When your application responds in milliseconds instead of seconds, users stay engaged. When your code uses memory efficiently, your cloud hosting bills shrink. When your architecture handles load gracefully, your system scales without breaking.
The Real Cost of Poor Performance
Consider a simple scenario: an e-commerce API that takes 2 seconds to process each order. During peak traffic with 1,000 concurrent users, this creates a bottleneck that could crash your system or lose sales. The same API optimized to handle requests in 200ms can serve 10x more users on the same infrastructure.
// Bad: Synchronous database calls block threadspublicclassOrderService{publicOrderProcessOrder(intorderId){varorder=_database.GetOrder(orderId);// Blocks threadvarinventory=_database.CheckInventory(order.ProductId);// Another blockreturnorder;}}// Good: Async operations free up threadspublicclassOrderService{publicasyncTask<Order>ProcessOrderAsync(intorderId){varorder=await_database.GetOrderAsync(orderId);// Non-blockingvarinventory=await_database.CheckInventoryAsync(order.ProductId);returnorder;}}Performance Metrics That Matter
Understanding what to measure is crucial. Response time, throughput, memory usage, and CPU utilization tell different parts of your performance story. A 50ms API response might seem fast, but if it consumes 500MB of memory per request, you have a scalability problem.
Modern applications also face unique challenges: microservices add network latency, cloud environments have variable performance, and users expect instant responses across devices.
Chapter Summary
Performance directly affects user experience, operational costs, and system scalability. Focus on measuring what matters: response times, throughput, memory usage, and CPU utilization. Async programming is fundamental to building scalable .NET applications.
Practical Exercise
Set up Application Insights or a similar monitoring tool in a sample .NET application. Identify your slowest endpoints and highest memory consumers. This baseline will guide your optimization efforts throughout this book.
Chapter 2: Understanding the .NET Runtime & JIT Optimizations
The .NET runtime and Just-In-Time (JIT) compiler work behind the scenes to optimize your code, but understanding how they operate helps you write performance-friendly code from the start.
How the JIT Compiler Optimizes Your Code
When your C# code runs, it's first compiled to Intermediate Language (IL), then the JIT compiler converts IL to native machine code. This two-step process enables powerful optimizations: dead code elimination, loop unrolling, and method inlining.
// The JIT compiler will inline this simple methodpublicstaticintAdd(inta,intb){returna+b;// Simple enough for inlining}// But complex methods won't be inlinedpublicstaticintComplexCalculation(int[]data){// Complex logic here - JIT won't inlinevarresult=0;for(inti=0;i<data.Length;i++){result+=data[i]*data[i];}returnresult;}Ahead-of-Time (AOT) Compilation
.NET 7+ introduces Native AOT, which compiles your entire application to native code ahead of time. This eliminates JIT compilation overhead and reduces startup time, crucial for serverless functions and containerized applications.
// AOT-friendly code avoids reflectionpublicclassProductService{// Good: Direct property accesspublicstringGetProductName(Productproduct)=>product.Name;// Avoid: Reflection-based access (AOT unfriendly)publicstringGetPropertyValue(objectobj,stringpropertyName){returnobj.GetType().GetProperty(propertyName)?.GetValue(obj)?.ToString();}}Runtime Configuration for Performance
The .NET runtime offers configuration options that significantly impact performance. Garbage collection modes, thread pool settings, and server vs workstation GC can be tuned for your specific workload.
<!-- Example runtime configuration --><configuration><runtime><gcServerenabled="true"/><!-- Use server GC for throughput --><gcConcurrentenabled="true"/><!-- Enable concurrent GC --></runtime></configuration>Chapter Summary
The JIT compiler optimizes your code at runtime, but you can write JIT-friendly code by keeping methods simple and avoiding complex patterns. Native AOT eliminates JIT overhead but requires avoiding reflection. Runtime configuration options let you tune the GC and thread pool for your workload.
Practical Exercise
Create two versions of a simple method: one complex enough that the JIT won't inline it, and one simple enough that it will. Use BenchmarkDotNet to measure the performance difference and examine the generated assembly code.
Chapter 3: Memory Management & Garbage Collection
Memory management in .NET is automatic, but understanding how garbage collection works helps you write code that minimizes GC pressure and avoids common memory pitfalls.
Understanding Generational Garbage Collection
The .NET garbage collector organizes objects into three generations. Generation 0 holds short-lived objects, Generation 1 holds medium-lived objects, and Generation 2 holds long-lived objects. Collections in Gen 0 are fast and frequent, while Gen 2 collections are expensive and rare.
// Bad: Creates many short-lived objectspublicstringProcessData(string[]inputs){varresult="";foreach(varinputininputs){result+=input.ToUpper();// Each concatenation creates new strings}returnresult;}// Good: Uses StringBuilder to reduce allocationspublicstringProcessData(string[]inputs){varresult=newStringBuilder();foreach(varinputininputs){result.Append(input.ToUpper());// Reuses internal buffer}returnresult.ToString();}Memory Pooling with ArrayPool
For scenarios where you frequently allocate and deallocate arrays, ArrayPool provides a way to reuse memory and reduce GC pressure.
publicclassDataProcessor{privatestaticreadonlyArrayPool<byte>_arrayPool=ArrayPool<byte>.Shared;publicbyte[]ProcessData(intsize){// Rent from pool instead of allocatingvarbuffer=_arrayPool.Rent(size);try{// Process data using bufferfor(inti=0;i<size;i++){buffer[i]=(byte)(i%256);}// Return actual data (copy what you need)varresult=newbyte[size];Array.Copy(buffer,result,size);returnresult;}finally{// Always return to pool_arrayPool.Return(buffer);}}}Span and Memory for Zero-Copy Operations
Span provides a stack-allocated way to work with contiguous memory without heap allocations. It's perfect for parsing, string manipulation, and buffer operations.
// Bad: Substring creates new string objectspublicstring[]SplitData(stringdata){returndata.Split(',');// Allocates array and strings}// Good: Uses ReadOnlySpan<char> for zero-copy parsingpublicvoidParseData(ReadOnlySpan<char>data,List<int>results){varremaining=data;while(remaining.Length>0){varcommaIndex=remaining.IndexOf(',');varsegment=commaIndex>=0?remaining[..commaIndex]:remaining;if(int.TryParse(segment,outvarvalue)){results.Add(value);}remaining=commaIndex>=0?remaining[(commaIndex+1)..]:ReadOnlySpan<char>.Empty;}}Chapter Summary
The .NET garbage collector uses generations to optimize collection performance. Minimize allocations by reusing objects, using StringBuilder for string concatenation, leveraging ArrayPool for temporary arrays, and using Span for zero-copy operations.
Practical Exercise
Write a method that processes a large CSV file. Create two versions: one that uses string.Split() and substring operations, and another that uses ReadOnlySpan and manual parsing. Measure the memory allocation difference using dotnet-counters.
Chapter 4: Efficient Data Structures & Collections
Choosing the right collection type dramatically impacts your application's performance. Each collection has specific strengths and trade-offs that affect memory usage, access patterns, and operation speed.
Understanding Collection Performance Characteristics
Different collections excel at different operations. List provides fast indexed access but slow insertions in the middle. HashSet offers O(1) lookups but no ordering. Dictionary balances fast lookups with key-value storage.
// Bad: Using List<T> for frequent lookupspublicclassUserManager{privatereadonlyList<User>_users=new();publicUserFindUser(intid){// O(n) linear search - slow for large collectionsreturn_users.FirstOrDefault(u=>u.Id==id);}}// Good: Using Dictionary<T> for fast lookupspublicclassUserManager{privatereadonlyDictionary<int,User>_users=new();publicUserFindUser(intid){// O(1) hash lookup - fast regardless of size_users.TryGetValue(id,outvaruser);returnuser;}}When to Use Specialized Collections
.NET provides specialized collections for specific scenarios. ConcurrentDictionary for thread-safe operations, SortedDictionary for ordered keys, and ImmutableList for functional programming patterns.
// Thread-safe caching with ConcurrentDictionarypublicclassCacheService{privatereadonlyConcurrentDictionary<string,object>_cache=new();publicTGetOrAdd<T>(stringkey,Func<T>factory){// Thread-safe get-or-add operationreturn(T)_cache.GetOrAdd(key,_=>factory());}}// Memory-efficient read-heavy scenarios with FrozenDictionary (.NET 8+)publicclassConfigurationService{privatereadonlyFrozenDictionary<string,string>_config;publicConfigurationService(Dictionary<string,string>config){// Create optimized read-only dictionary_config=config.ToFrozenDictionary();}publicstringGetValue(stringkey)=>_config.GetValueOrDefault(key);}Memory-Efficient Collection Strategies
Consider memory overhead when choosing collections. Each Dictionary entry has overhead beyond the key-value pair. For small collections, arrays or lists might be more efficient despite slower lookups.
// For small, frequently accessed collections, arrays can be fasterpublicclassStatusManager{// Small collection - array is more memory efficientprivatestaticreadonly(intId,stringName)[]Statuses={(1,"Active"),(2,"Inactive"),(3,"Pending")};publicstringGetStatusName(intid){// Linear search is fast for small collectionsforeach(var(statusId,name)inStatuses){if(statusId==id)returnname;}return"Unknown";}}Chapter Summary
Choose collections based on your access patterns: Dictionary for fast lookups, List for indexed access, HashSet for unique values, and specialized collections like ConcurrentDictionary for thread safety. Consider memory overhead and collection size when making decisions.
Practical Exercise
Create a benchmark comparing Dictionary, List>, and a simple array of tuples for storing and retrieving 10, 100, and 1000 items. Measure both performance and memory usage.
Chapter 5: LINQ & Performance
LINQ provides elegant, readable code, but its convenience can hide performance issues. Understanding deferred execution, materialization points, and query optimization helps you use LINQ effectively in performance-critical code.
Understanding Deferred Execution
LINQ queries use deferred execution—they don't run until you enumerate the results. This can lead to unexpected behavior where queries execute multiple times or hold onto resources longer than expected.
// Bad: Query executes multiple timespublicvoidProcessUsers(IEnumerable<User>users){varactiveUsers=users.Where(u=>u.IsActive);// Deferred - no execution yetConsole.WriteLine($"Count: {activeUsers.Count()}");// Executes query #1foreach(varuserinactiveUsers)// Executes query #2{Console.WriteLine(user.Name);}}// Good: Materialize once with ToList()publicvoidProcessUsers(IEnumerable<User>users){varactiveUsers=users.Where(u=>u.IsActive).ToList();// Execute onceConsole.WriteLine($"Count: {activeUsers.Count}");// Uses cached resultsforeach(varuserinactiveUsers)// Uses cached results{Console.WriteLine(user.Name);}}Avoiding LINQ Performance Pitfalls
Some LINQ operations are more expensive than others. Operations like Count(), Any(), and First() can be optimized for specific collection types, while others always enumerate the entire sequence.
// Bad: Inefficient LINQ chainspublicdecimalCalculateTotal(IEnumerable<Order>orders){returnorders.Where(o=>o.Status=="Completed").Select(o=>o.Items).SelectMany(items=>items)// Flattens collections.Where(item=>item.Price>0).Sum(item=>item.Price);// Multiple iterations}// Good: Combine operations for efficiencypublicdecimalCalculateTotal(IEnumerable<Order>orders){vartotal=0m;foreach(varorderinorders){if(order.Status=="Completed"){foreach(variteminorder.Items){if(item.Price>0){total+=item.Price;}}}}returntotal;}LINQ to Objects vs LINQ to Entities
LINQ to Objects executes in memory, while LINQ to Entities translates to SQL. Mixing them incorrectly can cause performance issues by bringing too much data into memory.
// Bad: Forces database query to return all datapublicasyncTask<List<User>>GetActiveUsersAsync(){varusers=await_context.Users.ToListAsync();// Loads ALL usersreturnusers.Where(u=>u.IsActive).ToList();// Filters in memory}// Good: Filter at database levelpublicasyncTask<List<User>>GetActiveUsersAsync(){returnawait_context.Users.Where(u=>u.IsActive)// Filters in SQL.ToListAsync();}Chapter Summary
LINQ's deferred execution can cause queries to run multiple times. Materialize results with ToList() when you'll enumerate multiple times. Combine LINQ operations efficiently, and ensure database filtering happens at the SQL level, not in memory.
Practical Exercise
Create a benchmark comparing a complex LINQ chain versus an equivalent foreach loop for processing a collection of 10,000 objects. Measure both execution time and memory allocations.
Chapter 6: Async & Parallel Programming Best Practices
Asynchronous and parallel programming are essential for building responsive, scalable .NET applications. However, incorrect usage can hurt performance more than help it.
Understanding Async/Await vs Parallelism
Async/await is designed for I/O-bound operations that would otherwise block threads. Parallel programming with Task.Run or Parallel.ForEach is for CPU-bound work that can benefit from multiple cores.
// Bad: Using Task.Run for I/O operationspublicasyncTask<string>GetDataAsync(){returnawaitTask.Run(async()=>{// Unnecessarily uses thread pool thread for async I/Ousingvarclient=newHttpClient();returnawaitclient.GetStringAsync("https://api.example.com/data");});}// Good: Direct async I/OpublicasyncTask<string>GetDataAsync(){usingvarclient=newHttpClient();returnawaitclient.GetStringAsync("https://api.example.com/data");}ConfigureAwait and Context Switching
In library code, use ConfigureAwait(false) to avoid capturing the synchronization context, which can improve performance and prevent deadlocks.
// Library method - avoid context capturepublicasyncTask<User>GetUserAsync(intid){varuserData=await_httpClient.GetStringAsync($"/users/{id}").ConfigureAwait(false);// Don't capture contextvaruser=JsonSerializer.Deserialize<User>(userData);returnawaitProcessUserAsync(user).ConfigureAwait(false);}// Application method - context capture is usually finepublicasyncTaskDisplayUserAsync(intid){varuser=awaitGetUserAsync(id);// Context capture OK for UI updatesUserNameLabel.Text=user.Name;}Efficient Parallel Processing
Use Parallel.ForEach for CPU-bound work on collections, but be aware of the overhead. For small collections or simple operations, a regular foreach loop might be faster.
// Good: Parallel processing for CPU-intensive workpublicvoidProcessImages(string[]imagePaths){varoptions=newParallelOptions{MaxDegreeOfParallelism=Environment.ProcessorCount// Don't over-subscribe};Parallel.ForEach(imagePaths,options,imagePath=>{// CPU-intensive image processingvarimage=LoadImage(imagePath);varprocessed=ApplyFilters(image);// Heavy computationSaveImage(processed,GetOutputPath(imagePath));});}Combining Async and Parallel Operations
When you have multiple async operations that can run concurrently, use Task.WhenAll to run them in parallel rather than sequentially awaiting each one.
// Bad: Sequential async operationspublicasyncTask<UserProfile>GetUserProfileAsync(intuserId){varuser=awaitGetUserAsync(userId);varorders=awaitGetUserOrdersAsync(userId);varpreferences=awaitGetUserPreferencesAsync(userId);returnnewUserProfile(user,orders,preferences);}// Good: Concurrent async operationspublicasyncTask<UserProfile>GetUserProfileAsync(intuserId){varuserTask=GetUserAsync(userId);varordersTask=GetUserOrdersAsync(userId);varpreferencesTask=GetUserPreferencesAsync(userId);awaitTask.WhenAll(userTask,ordersTask,preferencesTask);returnnewUserProfile(userTask.Result,ordersTask.Result,preferencesTask.Result);}Chapter Summary
Use async/await for I/O-bound operations and Parallel classes for CPU-bound work. Always use ConfigureAwait(false) in library code. Combine multiple async operations with Task.WhenAll for maximum concurrency, and be mindful of parallel processing overhead for small workloads.
Practical Exercise
Create a method that makes multiple HTTP requests. Implement it three ways: sequential awaiting, Task.WhenAll, and incorrectly using Task.Run. Measure the performance differences and thread usage.
Chapter 7: I/O & Networking Performance
I/O operations are often the bottleneck in modern applications. Understanding how to optimize file access, network calls, and streaming operations can dramatically improve your application's responsiveness.
Efficient File I/O Operations
Use async file operations to avoid blocking threads, and choose the right approach based on your data size and access patterns.
// Bad: Synchronous file reading blocks threadspublicstringReadConfigFile(stringpath){returnFile.ReadAllText(path);// Blocks calling thread}// Good: Async file operationspublicasyncTask<string>ReadConfigFileAsync(stringpath){returnawaitFile.ReadAllTextAsync(path);// Non-blocking}// For large files, use streamingpublicasyncTask<List<string>>ReadLargeFileAsync(stringpath){varlines=newList<string>();usingvarreader=newStreamReader(path);stringline;while((line=awaitreader.ReadLineAsync())!=null){lines.Add(line);}returnlines;}Optimizing HTTP Client Usage
HttpClient should be reused, not created for each request. Use HttpClientFactory to manage connection pooling and DNS refresh automatically.
// Bad: Creating HttpClient for each requestpublicasyncTask<string>GetDataAsync(stringurl){usingvarclient=newHttpClient();// Creates new connection each timereturnawaitclient.GetStringAsync(url);}// Good: Reusing HttpClient with factorypublicclassApiService{privatereadonlyHttpClient_httpClient;publicApiService(HttpClienthttpClient){_httpClient=httpClient;}publicasyncTask<string>GetDataAsync(stringurl){returnawait_httpClient.GetStringAsync(url);}}// Register in DI containerservices.AddHttpClient<ApiService>(client=>{client.BaseAddress=newUri("https://api.example.com/");client.Timeout=TimeSpan.FromSeconds(30);});Streaming for Large Data Processing
When working with large amounts of data, streaming prevents memory issues and improves perceived performance.
// Stream large responses instead of loading into memorypublicasyncTaskProcessLargeApiResponseAsync(stringurl){usingvarresponse=await_httpClient.GetAsync(url,HttpCompletionOption.ResponseHeadersRead);usingvarstream=awaitresponse.Content.ReadAsStreamAsync();usingvarreader=newStreamReader(stream);stringline;while((line=awaitreader.ReadLineAsync())!=null){// Process each line as it arrivesawaitProcessLineAsync(line);}}gRPC and HTTP/3 for High-Performance APIs
For service-to-service communication, gRPC offers better performance than REST APIs through binary serialization and HTTP/2 multiplexing.
// gRPC service definition (faster than REST)publicclassUserService:Users.UsersBase{publicoverrideasyncTask<UserResponse>GetUser(UserRequestrequest,ServerCallContextcontext){varuser=await_userRepository.GetByIdAsync(request.Id);returnnewUserResponse{Id=user.Id,Name=user.Name,Email=user.Email};}}// HTTP/3 client configurationservices.AddHttpClient("Http3Client",client=>{client.BaseAddress=newUri("https://api.example.com/");}).ConfigurePrimaryHttpMessageHandler(()=>newHttpClientHandler(){ServerCertificateCustomValidationCallback=HttpClientHandler.DangerousAcceptAnyServerCertificateValidator});Chapter Summary
Use async I/O operations to avoid blocking threads. Reuse HttpClient instances through HttpClientFactory. Stream large data instead of loading it entirely into memory. Consider gRPC for high-performance service-to-service communication.
Practical Exercise
Create a file processing application that reads a large CSV file. Implement three versions: synchronous File.ReadAllText, async File.ReadAllTextAsync, and streaming with StreamReader. Compare memory usage and responsiveness.
Chapter 8: Entity Framework Core & Database Performance
Database operations often become the primary bottleneck in applications. Entity Framework Core provides many features to optimize database performance, but they require careful configuration and usage.
Query Optimization Fundamentals
EF Core translates LINQ queries to SQL, but not all LINQ operations translate efficiently. Understanding the generated SQL helps you write better queries.
// Bad: N+1 query problempublicasyncTask<List<OrderDto>>GetOrdersAsync(){varorders=await_context.Orders.ToListAsync();returnorders.Select(o=>newOrderDto{Id=o.Id,CustomerName=o.Customer.Name,// Triggers separate query for each orderTotal=o.Items.Sum(i=>i.Price)// Another query for each order}).ToList();}// Good: Use Include to eager load related datapublicasyncTask<List<OrderDto>>GetOrdersAsync(){returnawait_context.Orders.Include(o=>o.Customer).Include(o=>o.Items).Select(o=>newOrderDto{Id=o.Id,CustomerName=o.Customer.Name,Total=o.Items.Sum(i=>i.Price)}).ToListAsync();}Efficient Data Loading Strategies
Choose the right loading strategy based on your access patterns. Eager loading with Include, lazy loading with proxies, or explicit loading with Load().
// Split queries for collections to avoid cartesian explosionpublicasyncTask<List<Blog>>GetBlogsWithPostsAsync(){returnawait_context.Blogs.Include(b=>b.Posts).Include(b=>b.Tags).AsSplitQuery()// Generates separate queries for each Include.ToListAsync();}// Projection for read-only scenariospublicasyncTask<List<BlogSummary>>GetBlogSummariesAsync(){returnawait_context.Blogs.Select(b=>newBlogSummary{Title=b.Title,PostCount=b.Posts.Count(),LatestPost=b.Posts.OrderByDescending(p=>p.CreatedAt).FirstOrDefault().Title}).ToListAsync();}Batch Operations and Bulk Updates
For operations affecting many records, use batch operations instead of processing records individually.
// Bad: Individual updates in a looppublicasyncTaskUpdateUserStatusesAsync(List<int>userIds,stringstatus){foreach(varidinuserIds){varuser=await_context.Users.FindAsync(id);user.Status=status;await_context.SaveChangesAsync();// Separate database round-trip for each}}// Good: Batch updatepublicasyncTaskUpdateUserStatusesAsync(List<int>userIds,stringstatus){varusers=await_context.Users.Where(u=>userIds.Contains(u.Id)).ToListAsync();foreach(varuserinusers){user.Status=status;}await_context.SaveChangesAsync();// Single database round-trip}// Even better: Bulk update with ExecuteUpdateAsync (.NET 7+)publicasyncTaskUpdateUserStatusesAsync(List<int>userIds,stringstatus){await_context.Users.Where(u=>userIds.Contains(u.Id)).ExecuteUpdateAsync(s=>s.SetProperty(u=>u.Status,status));}Caching and Connection Management
Implement intelligent caching strategies and optimize connection usage for better performance.
// Memory caching for frequently accessed, rarely changed datapublicclassUserService{privatereadonlyAppDbContext_context;privatereadonlyIMemoryCache_cache;publicasyncTask<User>GetUserByEmailAsync(stringemail){varcacheKey=$"user_email_{email}";if(_cache.TryGetValue(cacheKey,outUsercachedUser)){returncachedUser;}varuser=await_context.Users.AsNoTracking()// Read-only, no change tracking overhead.FirstOrDefaultAsync(u=>u.Email==email);if(user!=null){_cache.Set(cacheKey,user,TimeSpan.FromMinutes(15));}returnuser;}}Chapter Summary
Avoid N+1 queries with Include or projections. Use AsSplitQuery for multiple collections. Batch database operations instead of individual round-trips. Cache frequently accessed data with AsNoTracking for read-only scenarios.
Practical Exercise
Create a blog system with Posts, Authors, and Comments. Write queries that demonstrate the N+1 problem, then optimize them using Include, projections, and split queries. Use a profiler to examine the generated SQL.
Chapter 9: API Performance in ASP.NET Core
API performance directly impacts user experience and system scalability. ASP.NET Core provides numerous built-in optimizations and patterns to build high-performance web APIs.
Minimal APIs vs Controller-Based APIs
Minimal APIs in .NET 6+ offer lower overhead and faster startup times for simple scenarios, while controllers provide more features for complex APIs.
// Minimal API - lower overheadvarbuilder=WebApplication.CreateBuilder(args);varapp=builder.Build();app.MapGet("/users/{id:int}",async(intid,IUserServiceuserService)=>{varuser=awaituserService.GetUserAsync(id);returnuserisnotnull?Results.Ok(user):Results.NotFound();});// Controller-based - more features[ApiController][Route("[controller]")]publicclassUsersController:ControllerBase{privatereadonlyIUserService_userService;publicUsersController(IUserServiceuserService){_userService=userService;}[HttpGet("{id:int}")]publicasyncTask<ActionResult<User>>GetUser(intid){varuser=await_userService.GetUserAsync(id);returnuserisnotnull?Ok(user):NotFound();}}Response Caching and Compression
Implement multiple layers of caching and compression to reduce bandwidth and improve response times.
// Response caching middlewarepublicvoidConfigureServices(IServiceCollectionservices){services.AddResponseCaching();services.AddResponseCompression(options=>{options.EnableForHttps=true;options.Providers.Add<BrotliCompressionProvider>();options.Providers.Add<GzipCompressionProvider>();});}// Cache frequently accessed endpoints[HttpGet][ResponseCache(Duration=300,Location=ResponseCacheLocation.Any)]publicasyncTask<ActionResult<List<ProductDto>>>GetProducts(){varproducts=await_productService.GetProductsAsync();returnOk(products);}// ETags for conditional requests[HttpGet("{id}")]publicasyncTask<ActionResult<Product>>GetProduct(intid){varproduct=await_productService.GetProductAsync(id);if(product==null)returnNotFound();varetag=$"\"{product.LastModified.Ticks}\"";if(Request.Headers.IfNoneMatch.Contains(etag)){returnStatusCode(304);// Not Modified}Response.Headers.ETag=etag;returnOk(product);}Rate Limiting and Throttling
Protect your API from abuse and ensure fair resource usage with built-in rate limiting (.NET 7+).
// Rate limiting configurationpublicvoidConfigureServices(IServiceCollectionservices){services.AddRateLimiter(options=>{options.RejectionStatusCode=429;// Fixed window rate limitingoptions.AddFixedWindowLimiter("FixedWindow",limiterOptions=>{limiterOptions.Window=TimeSpan.FromMinutes(1);limiterOptions.PermitLimit=100;});// Sliding window for more sophisticated controloptions.AddSlidingWindowLimiter("SlidingWindow",limiterOptions=>{limiterOptions.Window=TimeSpan.FromMinutes(1);limiterOptions.PermitLimit=100;limiterOptions.SegmentsPerWindow=6;});});}// Apply rate limiting to endpoints[EnableRateLimiting("FixedWindow")][HttpPost]publicasyncTask<ActionResult<Order>>CreateOrder(CreateOrderRequestrequest){varorder=await_orderService.CreateOrderAsync(request);returnCreatedAtAction(nameof(GetOrder),new{id=order.Id},order);}JSON Serialization Optimization
System.Text.Json is faster than Newtonsoft.Json and offers source generation for even better performance.
// JSON source generation for AOT and performance[JsonSerializable(typeof(User))][JsonSerializable(typeof(List<User>))][JsonSerializable(typeof(ApiResponse<User>))]publicpartialclassApiJsonContext:JsonSerializerContext{}// Configure JSON options for performancepublicvoidConfigureServices(IServiceCollectionservices){services.ConfigureHttpJsonOptions(options=>{options.SerializerOptions.PropertyNamingPolicy=JsonNamingPolicy.CamelCase;options.SerializerOptions.DefaultIgnoreCondition=JsonIgnoreCondition.WhenWritingNull;options.SerializerOptions.TypeInfoResolverChain.Insert(0,ApiJsonContext.Default);});}Health Checks and Monitoring
Implement health checks to monitor your API's performance and dependencies.
// Health checks configurationpublicvoidConfigureServices(IServiceCollectionservices){services.AddHealthChecks().AddDbContext<AppDbContext>().AddRedis(connectionString).AddCheck("external-api",()=>{// Custom health check logicreturnHealthCheckResult.Healthy();});}// Custom health check endpoint with response cachingapp.MapHealthChecks("/health",newHealthCheckOptions{ResponseWriter=async(context,report)=>{context.Response.ContentType="application/json";varresponse=new{status=report.Status.ToString(),checks=report.Entries.Select(x=>new{name=x.Key,status=x.Value.Status.ToString(),duration=x.Value.Duration.TotalMilliseconds})};awaitcontext.Response.WriteAsync(JsonSerializer.Serialize(response));}});Chapter Summary
Choose Minimal APIs for simple endpoints and controllers for complex scenarios. Implement response caching, compression, and ETags to reduce bandwidth. Use built-in rate limiting to protect against abuse. Optimize JSON serialization with System.Text.Json and source generation.
Practical Exercise
Create a product catalog API with both Minimal API and controller implementations. Add response caching, rate limiting, and health checks. Use a load testing tool like NBomber to compare performance between different configurations.
Conclusion
Performance optimization in .NET is a journey of understanding, measuring, and iterating. The techniques covered in this book—from JIT optimizations to database query tuning—provide a comprehensive foundation for building fast, scalable applications.
Remember that premature optimization can be counterproductive. Always measure first, optimize second, and verify your improvements. The .NET ecosystem provides excellent tooling for this: BenchmarkDotNet for micro-benchmarks, Application Insights for production monitoring, and profilers like dotnet-trace for deep analysis.
Start with the fundamentals: async programming for I/O operations, efficient data structures for your use cases, and proper database query patterns. These changes often provide the biggest performance improvements with the least complexity.
As you apply these techniques, you'll develop an intuition for performance-friendly code patterns. This intuition, combined with solid measurement practices, will help you build .NET applications that scale gracefully and provide excellent user experiences.
The performance journey never ends—new .NET versions bring new optimizations, and evolving requirements demand fresh approaches. Keep learning, keep measuring, and keep optimizing.



Top comments (0)