Performance work in .NET is rarely about discovering a single magical API. It is about learning to recognize hot paths: the small pieces of code executed so often, or under enough contention, that their cost becomes the cost of the entire system.
A controller action that runs once per minute can afford a little ceremony. A serializer invoked for every request, a parser handling every Kafka message, or a cache lookup on every authorization check cannot. In those paths, a tiny allocation, an unnecessary async state machine, or a poorly scoped database query becomes visible in throughput, tail latency, and cloud spend.
This guide is a field manual for improving those paths without turning an otherwise readable .NET application into an archaeological site of micro-optimizations.
Start with a Performance Hypothesis
The most expensive optimization is the one aimed at the wrong bottleneck. Before replacing a List<T> with an ArrayPool<T> rental or rewriting LINQ, form a testable hypothesis:
"This endpoint is slow because JSON deserialization allocates large temporary strings under concurrent load."
That statement gives you something measurable. You can collect allocation data, inspect request traces, and compare results after a change.
For production services, combine several perspectives:
- Metrics reveal trends: request rate, p95 and p99 latency, garbage collection pauses, thread-pool queue length, and database duration.
- Distributed traces reveal where a request spent time across API, cache, database, and messaging boundaries.
- Profiles reveal CPU-heavy methods and allocation sources.
- Benchmarks isolate candidate code paths from noisy infrastructure.
Use dotnet-counters for a quick live view, dotnet-trace or a profiler for deeper investigation, and BenchmarkDotNet when evaluating a narrowly scoped implementation decision. A benchmark that says one method is 20% faster is useful only after you establish that the method matters in a real request path.
Treat Allocations as a Throughput Budget
Managed allocation is fast. The problem is not that every allocation is inherently bad; the problem is that sustained allocation creates garbage collection work. At high request rates, temporary arrays, strings, closures, and object graphs can convert an otherwise CPU-light API into a GC-heavy one.
A useful first move is to find allocation multipliers. These are allocations that occur per item inside a loop, per property while parsing input, or per message in a consumer.
Consider parsing a delimited payload. Creating substrings for every field looks harmless:
But it creates an array and a string for each segment. If the input is large or frequent, prefer a span-based parser that examines slices of the original text:
ReadOnlySpan<T> is not a universal replacement for strings or arrays. It is a stack-only view over existing memory. That restriction is precisely what makes it valuable: it encourages parsing and processing data without producing another object graph.
The same principle applies to UTF-8 data. If input arrives as bytes, avoid converting the entire payload to a string merely to inspect a few fields. APIs built around ReadOnlySpan<byte>, Utf8JsonReader, and System.Buffers can keep work close to the original representation.
Pool Carefully, Not Reflexively
ArrayPool<T> and ObjectPool<T> are powerful when allocating large or frequently reused buffers. They are also easy to misuse.
Pooling is appropriate when all of these are true:
- The object is expensive enough or frequent enough to affect allocation pressure.
- Its lifetime is short and well-defined.
- Callers can reliably return it.
- Retained memory will not become a larger problem than allocation.
For example, a batch processor that builds temporary byte buffers may benefit from ArrayPool<byte>.Shared:
The finally block is not optional. A missed return converts a pool into a memory retention mechanism. Also consider whether buffers contain credentials, tokens, or personal data. In those cases, clearing before return may be necessary despite its cost.
Avoid pooling tiny objects just because pooling exists. A pool introduces ownership rules, possible contention, and more failure modes. First eliminate unnecessary work; pool only where measurements show allocation pressure remains important.
Make Async Mean Non-Blocking
async improves scalability when it allows a thread to serve other work while waiting for I/O. It does not make CPU-intensive work faster, and it cannot rescue synchronous blocking hidden behind an asynchronous-looking API.
The classic hazards remain:
- Calling
.Resultor.Wait()on tasks. - Wrapping synchronous I/O in
Task.Runinside request handlers. - Performing expensive CPU work on the thread pool without controlling concurrency.
- Starting many independent operations without considering downstream capacity.
In an ASP.NET Core service, flow cancellation from the request boundary to every I/O operation:
Cancellation is not merely politeness. When clients disconnect or deadlines expire, continuing database calls and outbound HTTP requests consumes capacity for results nobody will use.
For library code that does not require a synchronization context, ConfigureAwait(false) can still be appropriate. In modern ASP.NET Core applications, there is no request synchronization context to capture, so it is usually not a meaningful performance lever there. Use it deliberately in reusable libraries rather than as an automatic ritual.
Bound Concurrency at the Edge of Scarce Resources
Unbounded concurrency often looks excellent in a development environment and collapses under production load. A hundred simultaneous requests may translate into a hundred database commands, outbound calls, or message-processing operations competing for finite pools and remote capacity.
Apply limits where a dependency becomes scarce. SemaphoreSlim, Channel<T>, and worker services each offer different shapes of backpressure.
A bounded channel is especially useful when producers should slow down rather than create an unbounded in-memory queue:
The capacity value should come from a real operating model: expected processing time, acceptable waiting time, memory per item, and downstream throughput. A queue is a contract about overload behavior, not just a container.
Shape Database Work Before Optimizing C#
A highly tuned C# loop cannot compensate for an inefficient query that returns ten times the required rows. For EF Core code on read-heavy paths, start with a few dependable defaults:
- Project only the columns needed by the response.
- Use
AsNoTracking()for data that will not be updated in that context. - Paginate intentionally; avoid loading a full result set to select one page in memory.
- Inspect generated SQL and query plans for important endpoints.
- Avoid accidental N+1 query patterns when traversing relationships.
Compiled queries can help for extremely frequent, stable query shapes, but they should follow query design and indexing work. The largest gains typically come from moving filtering, aggregation, and projection to the database correctly.
Optimize for a Service That Can Be Understood
The best hot-path improvements preserve operational clarity. Add a benchmark beside non-obvious low-level code. Document buffer ownership. Name concurrency limits after the resource they protect. Record before-and-after measurements in the pull request.
A performance change is complete only when it answers four questions:
- What was the observed bottleneck?
- What workload was measured?
- What changed in latency, throughput, and allocations?
- What trade-off did the new code introduce?
That discipline turns .NET performance from a collection of tricks into an engineering practice. The goal is not to make every line maximally clever. It is to make the few lines that run constantly calm, bounded, measurable, and boring under load.
No comments yet. Be the first to share your thoughts!