top of page

.NET Performance in Production: Finding and Fixing What's Actually Slow

  • ShiftQuality Contributor
  • Apr 10
  • 4 min read

Your .NET application passes all its tests. It works correctly. And in production, a request that should take 50ms takes 800ms, users are complaining, and you have no idea why.

The instinct is to start guessing. Maybe it's the database queries. Maybe it's the serialization. Maybe it's that LINQ expression someone said was inefficient. The instinct is almost always wrong. Performance intuition is unreliable even for experienced developers. The only reliable approach is to measure, identify the actual bottleneck, and fix that — not what you think the bottleneck is.

Measure Before You Touch Anything

The first rule of performance work: never optimize without a measurement that tells you what to optimize. Every minute spent optimizing the wrong code is a minute wasted — and worse, it makes the codebase more complex for zero user benefit.

BenchmarkDotNet for Isolated Measurements

When you suspect a specific piece of code is slow, BenchmarkDotNet gives you precise, reliable measurements.

[MemoryDiagnoser]
public class SerializationBenchmarks
{
    private readonly List<Order> _orders = GenerateOrders(1000);

    [Benchmark(Baseline = true)]
    public string SystemTextJson() =>
        JsonSerializer.Serialize(_orders);

    [Benchmark]
    public string NewtonsoftJson() =>
        JsonConvert.SerializeObject(_orders);
}

The MemoryDiagnoser attribute is critical — it reports allocation counts and bytes, which in .NET are often a bigger performance factor than CPU time. Garbage collection pauses caused by excessive allocations create latency spikes that don't show up in simple timing measurements.

dotnet-counters for Production Observation

dotnet-counters attaches to a running .NET process and shows live metrics — GC collections, thread pool queue depth, exception rates, HTTP request duration. It's the first tool to reach for when production is slow and you need to understand what's happening right now.

dotnet-counters monitor --process-id 1234 --counters System.Runtime,Microsoft.AspNetCore.Hosting

If GC Gen 2 collections are happening frequently, you have an allocation problem. If the thread pool queue is growing, you're saturating your threads — probably a sync-over-async issue or a blocking call.

Application Performance Monitoring

For ongoing production visibility, an APM tool (Application Insights, Datadog, New Relic, or the open-source OpenTelemetry) traces requests end-to-end. It shows you that the 800ms request spends 700ms waiting for a database query, or 500ms serializing a response, or 300ms in a third-party HTTP call.

Without APM, you're debugging with guesswork. With it, you're debugging with data.

The Usual Suspects

While you should always measure before optimizing, some problems show up in .NET production applications over and over.

N+1 Database Queries

The most common performance problem in Entity Framework applications. You load a list of orders, then for each order, you load the customer. Instead of 1 query for orders + 1 query for all customers (2 queries), you get 1 query for orders + N queries for each customer's record (N+1 queries).

// N+1 — one query per order's customer
var orders = await context.Orders.ToListAsync();
foreach (var order in orders)
{
    Console.WriteLine(order.Customer.Name); // lazy load per iteration
}

// Fixed — eager load in one query
var orders = await context.Orders
    .Include(o => o.Customer)
    .ToListAsync();

With 100 orders, the N+1 version makes 101 database round trips. The eager-loaded version makes 1. The difference is often 10-50x in real applications.

Sync Over Async

Calling .Result or .Wait() on an async method blocks a thread pool thread. Under load, this can exhaust the thread pool, causing all subsequent requests to queue — which looks like the entire application is slow, not just one endpoint.

// Dangerous under load — blocks a thread
var result = httpClient.GetStringAsync(url).Result;

// Correct — async all the way
var result = await httpClient.GetStringAsync(url);

The rule is absolute: async all the way down. If a method calls an async API, it should be async itself. One .Result in a hot path can bring down an application under sufficient load.

Excessive Allocations in Hot Paths

.NET's garbage collector is excellent, but it's not free. In hot paths — code that runs thousands of times per second — unnecessary allocations create GC pressure that manifests as latency spikes.

Common culprits: string concatenation in loops (use StringBuilder), LINQ chains that allocate intermediate collections (use Span<T> or pre-allocated buffers for truly hot paths), and boxing value types by casting them to interfaces.

// Allocates a new string per iteration
string result = "";
foreach (var item in items)
    result += item.ToString(); // new string allocation each time

// Single allocation
var sb = new StringBuilder();
foreach (var item in items)
    sb.Append(item.ToString());
string result = sb.ToString();

This only matters in hot paths. In code that runs once per request, the allocation difference is irrelevant. Don't litter your codebase with StringBuilder for string concatenation that happens ten times total.

Missing Database Indexes

If a query filters or sorts on a column that doesn't have an index, the database performs a full table scan. This is invisible in development with 100 rows and devastating in production with 1 million rows.

Check your slow queries against the table's indexes. The most common fix is a single CREATE INDEX statement. The most common cause is that the migration created the table but nobody added indexes for the query patterns that emerged later.

The Optimization Decision

Not everything slow needs to be fast. Before optimizing anything, answer:

Is this in the critical path? Code that runs during a user-facing request matters. Code that runs in a nightly batch job probably doesn't — unless the batch job is taking 8 hours and you need it under 2.

How many users does this affect? A slow endpoint that 90% of users hit every session is more important than a slow endpoint used by 2% of users once a month.

What's the improvement ceiling? If a request takes 800ms and 700ms is a database query, optimizing the remaining 100ms of .NET code can only save 100ms. Fix the query first.

Performance optimization is prioritization. Fix what matters most to the most users, starting with the largest bottleneck.

Key Takeaway

.NET performance work starts with measurement — BenchmarkDotNet for isolated code, dotnet-counters for live production, and APM for end-to-end tracing. The usual suspects are N+1 queries, sync-over-async blocking, excessive allocations in hot paths, and missing indexes. Always fix the measured bottleneck, not the one you imagine. And only optimize code that's actually in the critical path for real users.

Next in the .NET Beyond Basics path: Security practices that matter in .NET production applications — authentication, secrets management, and the vulnerabilities that actually get exploited.

Comments


bottom of page