.NET Core

.NET Performance Optimization: Profiling and Best Practices

15 min readFebruary 9, 2026Updated Mar 9, 2026
.NET performanceC# optimizationBenchmarkDotNet.NET profilingMemory management C#Span T C#Async performanceGC optimization

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:

bash
dotnet-counters monitor --process-id <PID> --counters System.Runtime

This 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:

bash
dotnet-trace collect --process-id <PID> --providers Microsoft-DotNETCore-SampleProfiler

BenchmarkDotNet

The gold standard for microbenchmarks. Never rely on Stopwatch for performance comparisons -- BenchmarkDotNet handles warmup, statistical analysis, and GC measurement automatically.

csharp
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:

csharp
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.

csharp
// 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:

csharp
// 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>:

csharp
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.

csharp
// 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:

csharp
// 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:

csharp
// 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.

csharp
// 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:

csharp
// 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:

csharp
// 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:

csharp
// 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:

csharp
// 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:

csharp
// 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:

csharp
// 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:

csharp
// 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

Have a Flutter Project?

I build high-performance Flutter applications for iOS, Android, and web.

Get in Touch