I'm always excited to take on new projects and collaborate with innovative minds.

Social Links

8 Advanced C# Mistakes Killing Your App's Performance (And How to Fix Them)

You stopped writing null-check bugs years ago. These 8 mistakes are subtler — they compile clean, pass code review, and still quietly tax your GC, your throughput, and your on-call sleep. Here's what they are and how to fix each one.

You stopped writing "forgot a null check" bugs a long time ago. Your code compiles, your tests pass, code review is clean. And your app is still slower than it should be.

That's the trouble with advanced mistakes: the runtime is doing exactly what you told it to do. You just didn't know what you were telling it. No exception gets thrown. Nothing crashes in staging. It just shows up three weeks later as a memory graph that keeps climbing, a thread pool that starves under load, or a GC that's working overtime for reasons nobody can point to.

Here are eight of the most common ones — what causes them, why they hurt, and the production-grade fix for each.

1. Closures That Quietly Allocate

A lambda that reaches for a local variable declared outside its own scope forces the compiler to generate a hidden "display class" just to hold that variable. Every time that lambda runs, you're paying for two allocations instead of zero: the display class instance, and the delegate itself.

// captures "threshold" → hidden display class allocation
var filtered = items.Where(x => x > threshold);

// static lambda: nothing to capture, nothing to allocate
var filtered = items.Where(static (x, t) => x > t, threshold);

Do this inside a loop that runs a few times a minute and nobody notices. Do it inside a hot path that runs a few million times a day, and you've built yourself a Gen0 garbage collection problem on purpose. The fix is to keep hot-path lambdas free of outer-scope captures — use the static modifier so the compiler enforces it, and pass any needed state in as a parameter instead.

2. Large Arrays Fragmenting the LOH

Any buffer larger than roughly 85KB skips .NET's normal generational heap and goes straight to the Large Object Heap (LOH). The catch: the LOH is swept for garbage but not compacted by default. Allocate and discard large arrays repeatedly — streaming pipelines and batch jobs are the usual culprits — and you fragment that heap over time.

// rent instead of allocating a fresh large array
var buffer = ArrayPool<byte>.Shared.Rent(size);
try
{
    // use buffer
}
finally
{
    ArrayPool<byte>.Shared.Return(buffer);
}

Nothing here technically "leaks," which is what makes it sneaky — you'll see it as a loh-size counter that keeps rising during exactly the operations you assumed were memory-safe. The fix is to rent large, short-lived buffers from ArrayPool<T>.Shared and always return them, ideally in a finally block so a thrown exception doesn't skip the return.

3. Virtual Calls the JIT Can't Optimize Away

Any call made through a base class or interface reference goes through virtual dispatch, because the JIT can't be certain at compile time which override is actually going to run. That indirection blocks inlining.

// open for inheritance → every call is virtual
public class PriceCalculator { public virtual decimal Calc() { /* ... */ } }

// sealed → JIT can devirtualize and inline
public sealed class PriceCalculator { public decimal Calc() { /* ... */ } }

For most code this overhead is irrelevant. Inside a loop that runs millions of times, it adds up — and it's invisible until it does, because nothing about it looks wrong in a code review. If a class isn't actually designed for extension, seal it. It costs you nothing and gives the JIT room to devirtualize and inline the call like any other method.

4. Captive Dependencies in Dependency Injection

If a singleton service constructor-injects a scoped or transient dependency, that dependency gets frozen for the entire lifetime of the app instead of being recreated per request or per scope.

// singleton constructor-injects a scoped DbContext → captive dependency
public class CacheWarmer(AppDbContext db) { /* ... */ }

// resolve a fresh scope on demand instead
using var scope = scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();

The result is a DbContext or scoped cache that outlives its intended lifetime by design, quietly leaking across unrelated requests — stale data, threading bugs, and database connections that never get recycled. Never resolve a shorter-lived service directly into a singleton's constructor. Inject IServiceScopeFactory and create a scope only when you actually need the dependency.

5. Fire-and-Forget Background Work

Kicking off background work with a bare Task.Run and moving on gives you no back-pressure, no ordering guarantee, and no reliable way to know the work actually completed — or that it threw an exception nobody caught.

// bounded producer/consumer queue
var channel = Channel.CreateBounded<Job>(capacity: 500);

await channel.Writer.WriteAsync(job);
await foreach (var j in channel.Reader.ReadAllAsync())
    Process(j);

Under real load, unbounded fire-and-forget work can be produced faster than it's consumed, spiking thread-pool usage and silently swallowing failures. Push that work through a Channel<T> with a single dedicated consumer instead. You get bounded capacity, predictable ordering, and a clean shutdown path without writing any of that plumbing yourself.

6. Caching a Task Instead of Its Result

Caching an async result the naive way — check the cache, call the expensive method if it's empty, store the result — has a race condition built in. Two concurrent callers can both see a cache miss and both trigger the expensive call at the same time.

// every caller shares the same in-flight task
private readonly Lazy<Task<Config>> _config =
    new(() => LoadConfigAsync());

public Task<Config> GetConfig() => _config.Value;

That's a cache stampede: your "cached" method quietly runs N times under concurrent load instead of once. The fix is to cache the Task<T> itself, not just its eventual result, using Lazy<Task<T>>. Every caller then awaits the same in-flight task instead of kicking off a new one.

7. Building Log Strings Nobody Will Ever Read

A string-interpolated log call formats and allocates the full message on every single invocation — even when that log level is disabled and the entire line gets discarded immediately after.

// formats the string even if Debug logging is off
logger.LogDebug($"Order {id} took {ms}ms");

// source-generated: skips formatting entirely when disabled
[LoggerMessage(Level = LogLevel.Debug, Message = "Order {Id} took {Ms}ms")]
partial void LogOrderTiming(int id, long ms);

At Debug or Trace volume, that's thousands of wasted string allocations per second for output nobody is watching. Compile-time [LoggerMessage] source-generated logging solves this by skipping the formatting work entirely when the log level is disabled, instead of building the string and throwing it away.

8. Mutable Structs and Silent Defensive Copies

When a mutable struct is accessed through a readonly field or property, the compiler has to make a hidden defensive copy before every mutation, just to guarantee the original value can't change out from under you unexpectedly.

// mutable struct behind a readonly field → hidden copy on every call
public struct Point { public int X; public void Move(int dx) => X += dx; }

// immutable: no defensive copy, intent is explicit
public readonly struct Point(int x)
{
    public int X { get; } = x;
    public Point MovedBy(int dx) => new(X + dx);
}

You call Move() expecting to mutate the original, the change silently disappears into a throwaway copy, and that copying overhead compounds across every call site touching the struct. Design value types as readonly struct with an immutable API from the start. If a value genuinely needs to change, return a new instance rather than mutating in place.

The Actual Takeaway: Measure First

None of these eight tricks matter until a profiler tells you they do. Default to clean, readable C# — reach for ArrayPool<T>, Channel<T>, sealed, or Lazy<Task<T>> only where tools like BenchmarkDotNet, dotMemory, or dotnet-counters have actually proven a hot path or a memory bottleneck exists. Optimizing blind is how you end up with code that's harder to read and no faster in practice.

Build resilient by default. Optimize with evidence, not instinct.

7 min read
Aug 11, 2026
By Dheer Gupta
Share

Leave a comment

Your email address will not be published. Required fields are marked *