.NET Core
.NET Performance Optimization: Profiling and Best Practices
Performance optimization in .NET should be evidence-driven. Benchmark and profile first, then optimize the highest-impact bottlenecks. Guessing where slowness lives almost always leads to wasted effort. In a high-throughput service I optimized, the initial assumption was that database calls were the bottleneck -- profiling revealed that excessive string allocations in a logging middleware were consuming 40% of Gen0 collections. The fix took 20 minutes once we knew where to look.
Measurement Toolkit
Before touching any code, you need the right instruments.
dotnet-counters
Real-time monitoring of runtime counters without restarting your application:
dotnet-counters monitor --process-id <PID> --counters System.RuntimeThis gives you live GC counts, allocation rates, thread pool queue lengths, and exception counts. It is the first thing I reach for when a service starts misbehaving in production.
dotnet-trace
Captures detailed execution traces that you can analyze in PerfView or Visual Studio:
dotnet-trace collect --process-id <PID> --providers Microsoft-DotNETCore-SampleProfilerBenchmarkDotNet
The gold standard for microbenchmarks. Never rely on Stopwatch for performance comparisons -- BenchmarkDotNet handles warmup, statistical analysis, and GC measurement automatically.
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
[MemoryDiagnoser]
[RankColumn]
public class StringConcatBenchmark
{
private readonly string[] _items = Enumerable.Range(0, 1000)
.Select(i => i.ToString())
.ToArray();
[Benchmark(Baseline = true)]
public string ConcatWithPlus()
{
var result = "";
foreach (var item in _items)
result += item;
return result;
}
[Benchmark]
public string ConcatWithStringBuilder()
{
var sb = new StringBuilder(4096);
foreach (var item in _items)
sb.Append(item);
return sb.ToString();
}
[Benchmark]
public string ConcatWithStringJoin()
{
return string.Join("", _items);
}
}
// In Program.cs:
BenchmarkRunner.Run<StringConcatBenchmark>();APM Tooling
Application Insights, Datadog, or OpenTelemetry for production-level latency and error trends. Microbenchmarks tell you what is possible; APM tells you what is actually happening under real load.
Profiling Workflow
A disciplined approach prevents you from optimizing the wrong thing.
Step 1: Define Targets
Establish concrete goals before writing any code. "Make it faster" is not a target. "P99 latency under 200ms for the /api/orders endpoint at 500 RPS" is a target.
Step 2: Capture a Baseline
Measure your current state under realistic load. Use a load testing tool like k6 or NBomber:
using NBomber.CSharp;
using NBomber.Http.CSharp;
var scenario = Scenario.Create("orders_api", async context =>
{
var request = Http.CreateRequest("GET", "https://localhost:5001/api/orders");
var response = await Http.Send(request);
return response;
})
.WithLoadSimulations(
Simulation.Inject(rate: 500, interval: TimeSpan.FromSeconds(1),
during: TimeSpan.FromMinutes(2))
);
NBomberRunner.RegisterScenarios(scenario).Run();Step 3: Profile and Identify
Run dotnet-trace or attach Visual Studio Profiler during the load test. Sort by inclusive CPU time or allocation count. Focus on the top 3 hotspots -- they usually account for 80% of the problem.
Step 4: Apply One Change at a Time
Never batch multiple optimizations. Each change should be isolated so you can measure its impact independently.
Step 5: Re-measure and Validate
Run the same load test with the same parameters. If the change does not produce a measurable improvement, revert it. Complexity without benefit is technical debt.
GC Optimization
Understanding the garbage collector is essential for high-performance .NET code.
Generational Collection
The .NET GC uses three generations:
- Gen0: Short-lived objects. Collected frequently and cheaply. Most objects should die here.
- Gen1: Buffer between short-lived and long-lived. Objects that survive Gen0 end up here.
- Gen2: Long-lived objects. Full collections are expensive and can cause noticeable pauses.
Reducing GC Pressure
The best garbage collection is the one that never happens. Every allocation you avoid is a collection you prevent.
// BAD: Allocates a new byte array on every call
public byte[] ProcessRequest(Stream input)
{
var buffer = new byte[4096];
int bytesRead = input.Read(buffer, 0, buffer.Length);
// process...
return buffer;
}
// GOOD: Rent from the shared pool
public void ProcessRequest(Stream input)
{
var buffer = ArrayPool<byte>.Shared.Rent(4096);
try
{
int bytesRead = input.Read(buffer, 0, buffer.Length);
// process...
}
finally
{
ArrayPool<byte>.Shared.Return(buffer);
}
}Span\<T\> for Zero-Allocation Parsing
Span<T> lets you slice memory without allocating new arrays or strings:
// BAD: Substring allocates a new string every time
public (string Key, string Value) ParseHeader(string header)
{
int colonIndex = header.IndexOf(':');
string key = header.Substring(0, colonIndex);
string value = header.Substring(colonIndex + 1).Trim();
return (key, value);
}
// GOOD: ReadOnlySpan avoids all allocations
public (ReadOnlySpan<char> Key, ReadOnlySpan<char> Value) ParseHeader(ReadOnlySpan<char> header)
{
int colonIndex = header.IndexOf(':');
var key = header[..colonIndex];
var value = header[(colonIndex + 1)..].Trim();
return (key, value);
}In a high-throughput service I optimized, switching HTTP header parsing from string.Split to Span<T>-based slicing eliminated roughly 12 MB/s of Gen0 allocations. The throughput increase was 35% with no logic changes.
Object Pooling
For expensive objects that are created and destroyed frequently, use ObjectPool<T>:
using Microsoft.Extensions.ObjectPool;
public class JsonSerializerPoolPolicy : PooledObjectPolicy<JsonSerializerOptions>
{
public override JsonSerializerOptions Create()
{
return new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = false,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
};
}
public override bool Return(JsonSerializerOptions obj) => true;
}
// Registration in DI
services.AddSingleton<ObjectPool<JsonSerializerOptions>>(
new DefaultObjectPool<JsonSerializerOptions>(new JsonSerializerPoolPolicy(), 32));
// Usage
public class OrderService
{
private readonly ObjectPool<JsonSerializerOptions> _optionsPool;
public OrderService(ObjectPool<JsonSerializerOptions> optionsPool)
{
_optionsPool = optionsPool;
}
public string Serialize(Order order)
{
var options = _optionsPool.Get();
try
{
return JsonSerializer.Serialize(order, options);
}
finally
{
_optionsPool.Return(options);
}
}
}Async Performance Pitfalls
Async/await is powerful but full of traps that silently kill throughput.
Sync-over-Async
Blocking on async code is the single most common performance killer I encounter in code reviews. It causes thread pool starvation and deadlocks.
// TERRIBLE: Blocks a thread pool thread waiting for async work
public string GetData()
{
// This can deadlock in ASP.NET and starves the thread pool
return _httpClient.GetStringAsync("https://api.example.com/data").Result;
}
// CORRECT: Async all the way down
public async Task<string> GetDataAsync()
{
return await _httpClient.GetStringAsync("https://api.example.com/data");
}ConfigureAwait(false) in Libraries
In library code, always use ConfigureAwait(false) to avoid capturing the synchronization context. This prevents deadlocks and improves performance:
// In library/shared code (not in controllers or UI)
public async Task<Order> GetOrderAsync(int id)
{
var json = await _httpClient
.GetStringAsync($"/orders/{id}")
.ConfigureAwait(false);
return JsonSerializer.Deserialize<Order>(json)!;
}Avoid Async State Machines for Trivial Cases
If you can return a completed task directly, skip the async machinery:
// Unnecessary: creates a full async state machine just to return a cached value
public async Task<Config> GetConfigAsync()
{
if (_cache.TryGetValue("config", out var config))
return config;
return await LoadConfigFromDatabaseAsync();
}
// Better: avoid the state machine for the hot path
public Task<Config> GetConfigAsync()
{
if (_cache.TryGetValue("config", out var config))
return Task.FromResult(config);
return LoadConfigFromDatabaseAsync();
}Bounding Concurrency
Unbounded parallelism is not concurrency -- it is a denial of service attack on your own downstream dependencies.
// DANGEROUS: fires thousands of HTTP calls simultaneously
var tasks = orderIds.Select(id => _httpClient.GetAsync($"/orders/{id}"));
await Task.WhenAll(tasks);
// CONTROLLED: limits to 20 concurrent requests
using var semaphore = new SemaphoreSlim(20);
var tasks = orderIds.Select(async id =>
{
await semaphore.WaitAsync();
try
{
return await _httpClient.GetAsync($"/orders/{id}");
}
finally
{
semaphore.Release();
}
});
await Task.WhenAll(tasks);Common Performance Mistakes
These are patterns I see repeatedly in production .NET codebases.
LINQ in Hot Paths
LINQ is elegant but allocates heavily. In tight loops, manual iteration wins:
// Allocates iterator, delegate, and potentially intermediate collections
var total = orders.Where(o => o.Status == OrderStatus.Completed)
.Sum(o => o.Amount);
// Zero-allocation alternative for hot paths
decimal total = 0;
foreach (var order in CollectionsMarshal.AsSpan(orders))
{
if (order.Status == OrderStatus.Completed)
total += order.Amount;
}Exception-Driven Control Flow
Throwing and catching exceptions is extremely expensive. Never use them for expected conditions:
// BAD: 50x slower than a simple null check
try
{
var order = _repository.GetOrder(id);
return order;
}
catch (NotFoundException)
{
return null;
}
// GOOD: Use pattern-based APIs
public Order? TryGetOrder(int id)
{
return _repository.FindOrder(id); // returns null if not found
}Logging Allocations
String interpolation in log calls executes even when the log level is disabled:
// BAD: Allocates the string even if Debug is disabled
_logger.LogDebug($"Processing order {order.Id} with {order.Items.Count} items");
// GOOD: Structured logging with deferred evaluation
_logger.LogDebug("Processing order {OrderId} with {ItemCount} items",
order.Id, order.Items.Count);Large Object Heap Fragmentation
Any allocation over 85,000 bytes goes to the Large Object Heap (LOH), which is collected only during Gen2 collections and can fragment:
// BAD: Allocates a 100KB+ array on the LOH every time
var largeBuffer = new byte[100_000];
// GOOD: Rent from the pool, which reuses LOH allocations
var largeBuffer = ArrayPool<byte>.Shared.Rent(100_000);
try
{
// use buffer...
}
finally
{
ArrayPool<byte>.Shared.Return(largeBuffer);
}Dictionary Capacity
Not pre-sizing collections when the count is known causes repeated resizing and re-hashing:
// BAD: Starts at default capacity, resizes multiple times
var lookup = new Dictionary<int, Order>();
foreach (var order in orders) // 10,000 orders
lookup[order.Id] = order;
// GOOD: Pre-allocate with known capacity
var lookup = new Dictionary<int, Order>(orders.Count);
foreach (var order in orders)
lookup[order.Id] = order;Data Access Optimization
Eliminating N+1 Queries
The N+1 problem is the most common database performance issue. It turns one query into hundreds:
// BAD: 1 query for orders + N queries for items
var orders = await _context.Orders.ToListAsync();
foreach (var order in orders)
{
// Each iteration triggers a lazy-load query
var items = order.Items.ToList();
}
// GOOD: Single query with eager loading
var orders = await _context.Orders
.Include(o => o.Items)
.AsSplitQuery()
.ToListAsync();Projection for Read-Only Scenarios
Never load full entities when you only need a few fields:
// BAD: Loads entire entity graph into memory and tracks changes
var orders = await _context.Orders.Include(o => o.Customer).ToListAsync();
// GOOD: Project only what you need, no change tracking overhead
var orderSummaries = await _context.Orders
.AsNoTracking()
.Select(o => new OrderSummaryDto
{
Id = o.Id,
CustomerName = o.Customer.Name,
Total = o.Items.Sum(i => i.Price * i.Quantity)
})
.ToListAsync();Conclusion
Sustainable .NET performance comes from disciplined measurement, targeted changes, and continuous regression monitoring. The pattern is always the same: measure, identify the actual bottleneck, fix it, verify the fix, and move on. Resist the urge to optimize speculatively -- every optimization adds complexity, and complexity without proven benefit is just technical debt.
The most impactful optimizations I have applied in production have been reducing allocations in hot paths with Span<T> and ArrayPool, fixing sync-over-async thread starvation, and eliminating N+1 queries. These three categories account for the vast majority of real-world .NET performance issues.
I can help run a structured performance audit and optimization plan for your .NET services.
Related Articles
Building RESTful APIs with ASP.NET Core
Learn the fundamentals of building production-ready REST APIs with ASP.NET Core. Controllers, routing, and best practices.
Entity Framework Core: The Modern Way to Handle Databases
Manage database operations with Entity Framework Core. Code-first approach, migrations, and performance optimization.
Caching Strategies in .NET: In-Memory, Distributed, and Redis
Implement effective caching strategies in .NET. In-memory cache, distributed cache, and Redis integration.
Have a Flutter Project?
I build high-performance Flutter applications for iOS, Android, and web.
Get in Touch