Most .NET performance work should not begin with a rewrite, a new architecture, or a hunt for the fastest collection type. It should begin with a question:
Which code runs often enough that small costs become system behavior?
That is the hot path: a request handler called thousands of times per minute, a serializer in every API response, a message consumer processing a continuous stream, or a data-mapping loop that quietly allocates an object graph for every row.
Advanced .NET optimization is less about memorizing tricks and more about developing an audit discipline. The goal is to identify work that is repeated, measure its cost, and remove only the overhead that matters.
This article walks through seven techniques for auditing .NET hot paths without turning maintainable code into an unreadable collection of micro-optimizations.
1. Start With Evidence, Not Intuition
A method can look expensive and be irrelevant. Another can look harmless and consume a large portion of CPU time because it runs on every request.
Before changing code, collect production-like evidence. At minimum, inspect:
- Request latency percentiles: p50, p95, and p99
- CPU utilization under representative load
- Allocation rate and Gen 0/Gen 2 GC activity
- Thread pool queue length and thread count
- Database command duration and command frequency
- External HTTP dependency duration
For local investigation, tools such as dotnet-counters, dotnet-trace, and dotnet-gcdump provide a strong baseline. For a service running in a container, make profiling part of an intentional diagnostic workflow rather than an emergency-only activity.
A useful first command is:
If allocation rate rises sharply while throughput remains stable, investigate object creation. If CPU is low but request latency rises, investigate waiting: database queries, locks, socket exhaustion, thread pool starvation, or downstream dependencies.
The important distinction is this: high latency does not automatically mean slow CPU code. It often means code is waiting inefficiently.
2. Treat Allocations as a Budget on Frequently Executed Paths
The garbage collector is one of .NET's major strengths. The lesson is not “avoid allocations at all costs.” The better lesson is: avoid unnecessary allocations in code that executes constantly.
Common allocation sources are surprisingly ordinary:
- LINQ chains in request-processing loops
- String interpolation in disabled log statements
- Repeated
ToList(),ToArray(), orstring.Split()calls - Captured lambdas and closures
- Per-request creation of serializers, regular expressions, or buffers
- Materializing database results that only need projection
Consider a validation routine that repeatedly filters a collection:
This may be perfectly appropriate in orchestration code. But if it appears in a high-throughput loop, the intermediate iterator objects and set construction may be meaningful. First ask whether the result can be cached, passed in, or built once at a more appropriate boundary.
When parsing or slicing text, ReadOnlySpan<char> can reduce temporary string allocations:
Spans are powerful, but they have constraints: they are stack-oriented and cannot safely cross await boundaries in the same way ordinary managed objects can. Use them where parsing is genuinely hot, not as a replacement for every string API.
3. Make Logging Cheap When It Is Not Needed
Logging can become a hidden allocation pipeline. This is especially common when structured log messages use interpolation:
The string is formatted before the logger decides whether Debug logging is enabled. In a high-volume endpoint, that means work occurs even when the message will never be emitted.
Prefer structured templates:
For heavily used logging sites, source-generated logging is even better. It avoids repeated template parsing and reduces overhead:
Call it directly:
This is not merely a benchmark exercise. Consistent structured logging also improves observability: fields remain queryable, event IDs stay stable, and dashboards do not need to parse human-formatted strings.
4. Follow Async All the Way Down
Async code is not automatically scalable. It is scalable when it avoids blocking a thread while waiting for I/O.
The classic hazards remain:
and:
Calling .Result or .Wait() can block request threads and contribute to thread pool starvation. Wrapping naturally asynchronous I/O in Task.Run does not make it asynchronous; it usually adds a scheduling hop and consumes another worker thread.
A healthier rule is simple:
- Use
asyncandawaitfor I/O-bound operations. - Use
Task.Runonly to offload bounded CPU work when that is truly required. - Pass
CancellationTokenthrough every operation that supports it.
For example:
Cancellation is part of correctness, not decoration. When a client disconnects or a request deadline expires, continuing database work wastes connection capacity and competes with useful requests.
5. Use ValueTask Only When the Fast Path Is Usually Synchronous
ValueTask<T> can avoid allocating a Task<T> when an operation often completes synchronously. That makes it useful for APIs backed by caches, buffers, or in-memory lookups.
For example, a cache lookup may return immediately most of the time:
But ValueTask is not a universal replacement for Task. It complicates consumption rules: callers should generally await it once and should not assume it can be stored, awaited repeatedly, or combined freely with every task-based API.
Use it only after measurement shows that task allocation on a common synchronous completion path is material. For most service methods, Task remains the clearer default.
6. Shape EF Core Queries Before They Reach Memory
Database performance is often application performance. An API can have immaculate C# code and still be slow because it retrieves too much data, tracks entities it will never modify, or triggers additional queries during serialization.
For read endpoints, start with three habits:
- Project only the fields required by the response.
- Use
AsNoTracking()when updates are not needed. - Inspect generated SQL and query count under realistic scenarios.
Avoid this pattern for list endpoints:
Instead, define the response shape inside the query:
This reduces transferred columns, avoids change-tracker work, makes pagination explicit, and prevents accidental serialization of large navigation graphs.
For truly hot, stable queries, compiled queries can be worth evaluating. They reduce repeated query compilation overhead, but they do not fix missing indexes, poor projections, or an inefficient query plan. Optimize the database shape first.
7. Pool Expensive Reusable Resources, Not Domain State
Pooling is valuable when an object is expensive to create and can be safely reset before reuse. Good candidates include buffers, parsers, and selected serializer-related resources.
ArrayPool<T> is particularly useful for temporary byte buffers in networking, compression, or file processing:
The finally block is mandatory. A rented array is shared infrastructure, and failure to return it can create the memory pressure pooling was intended to avoid.
Do not pool ordinary DTOs, entities, or mutable business objects merely to reduce allocations. The lifecycle complexity, stale state risks, and concurrency bugs usually cost more than the garbage collector would.
Build a Repeatable Hot Path Review
A mature performance culture turns these techniques into a lightweight review checklist:
- Is this method on a measured hot path?
- Does it allocate avoidable objects per operation?
- Does logging perform formatting when the level is disabled?
- Does every I/O call remain asynchronous and cancellable?
- Does the database query fetch only what the caller needs?
- Is pooling justified by measurement and safe reset behavior?
- Did the change improve a benchmark, trace, or production metric?
The best .NET optimization is often invisible to users and nearly invisible in code review. A request completes with fewer allocations. A canceled request stops earlier. A query returns less data. A log statement no longer formats a message nobody reads.
Those small changes are not isolated tricks. Together, they create services that retain their responsiveness when traffic rises, dependencies slow down, and the comfortable assumptions of local development disappear.
No comments yet. Be the first to share your thoughts!