October 21, 2026

.NET Performance Optimization: Best Practices to Improve Application Speed
Practical .NET performance optimization tips: profile, fix async code, tune EF Core, and cache smartly to speed up ASP.NET Core apps.


Practical .NET performance optimization tips: profile, fix async code, tune EF Core, and cache smartly to speed up ASP.NET Core apps.
Here's something most teams learn the hard way: a slow .NET application is almost never slow everywhere. Three queries, one blocking call, and a loop that allocates too much will do most of the damage. Find those first and the rest of the work gets a lot smaller.
Picture a checkout page that loads in 800 milliseconds on a developer's laptop. Then Friday's sale starts, and it takes six seconds. The code didn't change. What changed is everything around it: real traffic, a database with a few million more rows, and a dozen requests fighting for the same connection pool.
So what do we mean by .NET performance optimization? It's the process of measuring where an application spends time and memory, then removing waste starting with the biggest offenders. That touches code, data access, caching, infrastructure, and monitoring, which is why .NET performance tuning is as much about habits as it is about syntax. And it never really ends. Features ship, tables grow, traffic shifts, and yesterday's fast endpoint becomes today's support ticket.
The runtime usually isn't to blame. .NET has gotten noticeably faster over the last several releases, and moving to a current version like .NET 10 can help without a single code change. Still, a faster runtime can't fix an N+1 query. It can't unblock a thread that your own controller is holding hostage, either. The table below links common symptoms to their usual causes, so you know where to dig first.
| Symptom | Common cause | First tool to try |
|---|---|---|
| Slow API responses under load | Blocking calls, thread pool starvation | dotnet-counters, Application Insights |
| High CPU utilization | Inefficient loops, heavy serialization, LINQ in hot paths | Visual Studio Profiler, dotnet-trace |
| Memory that keeps growing | Large object allocations, leaked event handlers, unbounded caches | dotnet-gcdump, Visual Studio memory profiler |
| Slow pages that query a database | N+1 queries, missing indexes, over-fetching columns | EF Core logging, SQL execution plans |
| Fast locally, slow in production | Network latency, cold starts, undersized instances | Azure Monitor, distributed tracing |
Every senior developer says "profile first." Far fewer teams actually do it, and I get why. Looking at a method and thinking "that's obviously the slow part" feels productive. It's also wrong surprisingly often. The method that looks expensive might run twice a day, while some innocent property getter gets called ten million times.
To optimize .NET application performance without guessing, set a baseline. Track response time at the 95th and 99th percentiles, not the average, since averages bury the slow requests that annoy your users. Add throughput, CPU utilization, memory, and garbage collection (GC) pause time. If you can't compare before and after, you can't claim a win.
Different tools answer different questions, so here's a quick guide.
| Tool | Best for | Keep in mind |
|---|---|---|
| BenchmarkDotNet | Comparing two versions of one method | Micro-level only, not whole applications |
| Visual Studio Profiler | CPU, memory, and async analysis while developing | Best on a dev or staging machine |
| dotnet-trace and dotnet-counters | Diagnosing running services, containers included | Command-line, works cross-platform |
| Application Insights and Azure Monitor | Production request traces and dependency timings | Sampling choices affect cost |
| OpenTelemetry | Vendor-neutral traces and metrics across services | Needs some planning up front |
For .NET application monitoring in production, an Application Performance Monitoring (APM) tool gives you traces, dependency timings, and failure rates in one place. If you run several services, OpenTelemetry is worth the setup, mostly because it keeps you from being locked to one vendor. Whatever you choose, set it up before the next incident. Nobody wants to install monitoring while the site is down.
Got time for exactly one ASP.NET Core performance fix? Make it this one.
ASP.NET Core handles requests with a limited pool of threads. If your code blocks one of them while it waits for a database or an HTTP call, that thread can't help anyone else. A quiet Tuesday won't expose it. A busy Friday will: requests pile up, latency climbs, and the pool can't keep up. That's thread pool starvation, and it's behind a lot of "it worked fine in testing" stories.
You can usually spot the causes in a code review. Calling .Result or .Wait() on a task. Wrapping synchronous work in Task.Run inside a controller. Using synchronous database or file APIs in a request handler. The cure is asynchronous programming from top to bottom, so threads go back to the pool whenever they're waiting on I/O.
Two smaller habits help too. If a handler needs data from several independent sources, kick those calls off together and await them with Task.WhenAll instead of one at a time. And pass the request's CancellationToken into your database and HTTP calls, so a user who closes the tab doesn't keep your server busy.
The garbage collector does a lot for you, but it isn't free. Every allocation becomes work for the GC later, and frequent collections can show up as latency spikes. If you're wondering how to reduce memory usage in .NET applications, two facts help. Short-lived objects are cheap because they're cleaned up quickly in generation 0. Objects of 85,000 bytes or more land on the Large Object Heap, which is collected less often and can fragment. Big arrays, and strings built by concatenating in a loop, are the usual way to end up there.
The fixes aren't glamorous. Use StringBuilder instead of concatenating strings in loops. Rent big buffers from ArrayPool<T> rather than allocating a new array on every request. Slice data with Span<T> instead of copying it. Create HttpClient through IHttpClientFactory so connections get reused. Stream large files instead of loading them whole, and dispose of anything that implements IDisposable.
A quick word on LINQ, since it's a favorite scapegoat. It's convenient, and for most code it's fast enough. Trouble starts in hot paths: enumerating the same deferred query three times, calling ToList() halfway through a chain, or writing Count() > 0 when Any() does the job. In a loop that runs millions of times, those allocations add up. Rewrite only what the profiler flags.
GC settings are a separate topic. Server and workstation GC behave differently, and container CPU limits change which one makes sense. Test under realistic load rather than copying a config from a forum post.
Most business applications spend most of their time waiting on a database. So if you want to know how to improve Entity Framework performance, start by reading the SQL that EF Core actually sends. Turn on query logging. You'll probably be surprised.
The classic offender is the N+1 query: the code loads a list, then runs one more query for every item in it.
// Slow: one query for orders, then one more per order
var orders = await db.Orders.ToListAsync();
foreach (var order in orders)
{
var items = await db.OrderItems
.Where(i => i.OrderId == order.Id)
.ToListAsync();
}
// Better: one query, only the data the screen needs
var summaries = await db.Orders
.AsNoTracking()
.Select(o => new OrderSummary(o.Id, o.Total, o.Items.Count))
.ToListAsync();The second version also skips change tracking and pulls just three values, so the database does less and your process holds less in memory. Here are the techniques worth knowing for Entity Framework Core, along with what each one costs you.
| Technique | What it fixes | Trade-off |
|---|---|---|
| AsNoTracking() | Tracking overhead on read-only queries | Entities can't be saved back directly |
| Projection with Select | Fetching columns nobody uses | Requires DTOs or view models |
| Include or split queries | N+1 query patterns | Big joins can multiply rows; split queries add round trips |
| Pagination, ideally keyset | Loading huge result sets | Keyset paging is harder than Skip and Take |
| Compiled queries | Repeated query translation cost | Only worth it on proven hot paths |
| ExecuteUpdate and ExecuteDelete | Loading rows just to change or remove them | Bypasses the change tracker |
| Database indexes | Table scans | Slower writes, more storage |
Database query optimization doesn't stop at EF Core, either. Read the execution plan for your slowest queries and look for missing indexes. For a few read-heavy endpoints, dropping down to raw SQL or Dapper is a perfectly reasonable call. The goal is a fast application, not loyalty to one library.
Caching is the fastest way to delete work, and the easiest way to ship a stale-data bug. Which type you pick depends on two questions: does the data need to be shared across servers, and how out of date can it safely be?
| Strategy | Scope | Best for | Watch out for |
|---|---|---|---|
| In-memory caching (IMemoryCache) | One server process | Small, rarely changing reference data | Every instance holds its own copy |
| Distributed caching (Redis) | Shared across instances | Shared lookups, session-like data | Network hop and serialization cost |
| HybridCache | In-process plus distributed layers | Most new .NET applications | Needs consistent key naming |
| Output caching | Entire HTTP responses | Public endpoints that change slowly | Personalized responses |
HybridCache is the newer option and deserves a look if you're on a recent .NET version. It pairs a fast local cache with a distributed one behind a single API. It also guards against cache stampedes, where a crowd of requests for the same missing key all hit your database together. Microsoft's documentation points out that this coordination doesn't reach across separate HybridCache instances, so don't assume it covers everything.
public async Task<ProductDto?> GetProductAsync(int id, CancellationToken ct)
{
return await cache.GetOrCreateAsync(
$"product:{id}",
async token => await repository.GetProductAsync(id, token),
cancellationToken: ct);
}A few ASP.NET Core caching best practices hold no matter which option you pick. Set expirations on purpose instead of living with defaults. Name your keys consistently, or invalidation turns into a nightmare. Keep user-specific data out of shared keys. And watch your hit rate. A cache that rarely hits is just an extra hop.
Learning how to optimize .NET API performance often comes down to sending less data, less often. Return DTOs instead of full entities. Paginate list endpoints. Turn on response compression for large text payloads. Stick with System.Text.Json, and consider its source generation on hot serialization paths to cut reflection overhead.
Then protect the thing. ASP.NET Core ships rate limiting and request timeout middleware, and both keep one slow dependency or one noisy client from dragging everyone else down. For service-to-service calls inside your network, gRPC over HTTP/2 can cut payload size and latency compared with JSON over REST. And if startup time matters, say for serverless functions or containers that scale fast, Native AOT is available for minimal APIs. It limits reflection-heavy code, so check your dependencies first.
A load test shows how the application behaves at expected traffic. A stress test goes beyond that to find where things break, and how gracefully. k6, NBomber, Apache JMeter, and Azure Load Testing all work well with .NET services.
You don't need an elaborate suite. Pick your three busiest or most critical endpoints, write a repeatable test, and run it against staging in your CI/CD pipeline with a clear threshold, like a 95th percentile response time your users can live with. When a pull request breaks the threshold, you hear about it in minutes, not after release.
Scaling comes last, not first. Adding instances in Azure can absorb more traffic, but it also hides inefficiency and grows your bill. Autoscale rules tied to CPU can trail behind a sudden spike. Fix the hot path, then scale whatever's left.
When someone says "the app is slow," this order saves the most time.
None of these .NET performance best practices demand a rewrite. Most fixes are a few lines of code, guided by data instead of hunches.
Some performance work makes a great internal project. Some doesn't: legacy .NET Framework systems with years of accumulated queries, architectures that have hit their ceiling, or teams that have never profiled a production service. In those cases, a second set of experienced eyes tends to pay for itself fast.
Buoyancy Software helps in both situations. If your team needs senior engineers to tune, upgrade, or stabilize an existing application, you can hire dedicated .NET developers who've done this before. And if the smarter move is rebuilding a slow component instead of patching it again, our custom application development services cover design through deployment.
Slow pages cost you users, and slow APIs cost you sleep. Our .NET engineers can review your application, pinpoint the bottlenecks that matter, and hand you a prioritized plan to fix them. Book a Demo and see how Buoyancy Software can speed up your application and keep it fast as it grows.
Get answers to the most common questions
about our products, services, and policies.
