ASP.NET Core is fast by default, and most of the slowdowns in real apps trace back to a handful of habits: blocking on async code, loading too much data in one go, and putting heavy work on code that runs for every request. This post collects those ASP.NET Core best practices in one place, along with a few rules about HttpContext and middleware that are easy to break. You can skim it before a code review. It isn’t a complete guide, and Microsoft’s own best-practices page, linked at the end, goes into more detail.
ASP.NET Core performance basics
Start with the hot code paths. These run on almost every request and account for most of the execution time: middleware, logging, authorization handlers and the construction of transient services. Anything slow there limits how far the app can scale out, so profile these first.
Never block on asynchronous work. Calling Task.Wait or .Result holds a thread pool thread while it waits, and under load that leads to thread pool starvation. Async APIs let a small pool of threads serve thousands of concurrent requests because no thread sits idle waiting for I/O. Use async and await all the way from the controller down to the data layer.
Cache aggressively. ASP.NET Core supports in-memory caching, distributed caching, response caching and, from .NET 7 on, output caching. Data that is read often and changes rarely is the first thing to cache.
Page large collections. A result set returned in one piece can cause an OutOfMemoryException, high memory use, slow responses and frequent garbage collection, and a user rarely needs ten thousand rows on one screen.
Data access and memory
Objects of 85,000 bytes or more are allocated on the large object heap, and garbage collection there is expensive. Keep large allocations out of hot paths, and reuse buffers with ArrayPool<T> where it makes sense.
Calls to the database and other remote services are usually the slowest part of an ASP.NET Core app. For data access in general, and Entity Framework Core in particular:
- Call every data access API asynchronously.
- Fetch only the data you need. Filter and aggregate in the LINQ query so the database does that work, not your web server.
- Use AsNoTracking() for read-only queries, since change tracking costs memory and time you don’t need when nothing will be saved.
- Reduce network round trips.
- Avoid projection queries on collections, which can turn into N+1 SQL queries.
Long-running work and SignalR
Work that takes minutes should not run inside an HTTP request. Hand it to a background service and return a response straight away. If the client needs to know when the job finishes, SignalR can push the update to the browser asynchronously.
Smaller, faster responses
Bundle and minify JavaScript and CSS, and optimize images, so the first page load needs fewer and smaller requests. A CDN will serve those static files faster than your app server can. Response compression shrinks the payload of everything else; if the app sits behind IIS, Nginx or a load balancer that can compress, let the server do it, because server-level compression usually performs better than the middleware.
Exceptions and upgrades
Throwing exceptions is slow. Keep them for situations that are actually exceptional and don’t use them for normal program flow: check for the condition that would throw, such as a missing record or invalid input, and handle it directly. For the errors you can’t predict, a global exception filter or exception-handling middleware gives you one place to log them and return a consistent response.
Stay on a current version. Every release of ASP.NET Core has included performance improvements, and older versions eventually lose support.
Keeping the code maintainable
These aren’t performance rules, but they keep an ASP.NET Core MVC codebase easy to work in:
- Put validation attributes such as [Required] and [StringLength] on your models so bad data is rejected before it reaches your logic.
- Use view models to keep presentation logic separate from business logic. Controllers get thinner and the code is easier to test.
- Handle cross-cutting concerns like logging, caching and authentication in middleware, or in action filters when they only apply to some controllers.
- Define routes with attribute routing, next to the actions they map to.
- Reuse shared UI with partial views instead of copying markup between pages.
HttpContext, middleware and IIS
Read forms with ReadFormAsync
Accessing HttpContext.Request.Form directly reads the request body synchronously, which blocks a thread and can starve the thread pool. Call await HttpContext.Request.ReadFormAsync() instead. After that call the form is cached, and reading Request.Form is safe. The same applies to the HttpRequest and HttpResponse bodies in general: all I/O in ASP.NET Core is asynchronous, so use the async overloads.
Don’t capture HttpContext or scoped services in background threads
HttpContext is only valid while the request is running. When a closure in a controller captures it and passes it to a background task, that task can run after the request has ended, or read an HttpContext that now belongs to a different request. Copy the values you need into local variables during the request, and don’t reference anything from the controller inside the task.
Injected services have the same problem. A DbContext like ContosoDbContext is scoped to the request, so a background task that captured it will throw an ObjectDisposedException once the request is over. Inject IServiceScopeFactory and create a new dependency injection scope inside the background work:
[HttpPost("/orders/{id}/export")]
public IActionResult Export(int id, [FromServices] IServiceScopeFactory scopeFactory)
{
_ = Task.Run(async () =>
{
await using var scope = scopeFactory.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<ContosoDbContext>();
// work with db and id here, not with anything from the controller
});
return Accepted();
}
For anything that runs regularly or takes more than a few seconds, implement it as a hosted service instead.
Set headers before the response body starts
ASP.NET Core doesn’t buffer the response body. When the first chunk is written, the status code and headers are sent with it, and after that they can’t be changed. If a middleware needs to add or override headers, register a callback with HttpResponse.OnStarting. It runs just before the headers are flushed to the client, and the middleware doesn’t need to know what comes after it in the pipeline. You can also check HttpResponse.HasStarted before touching headers.
Don’t call next() after writing to the response
Components further down the pipeline expect to be able to change the response. Once a middleware has started writing the body they can’t, so calling next() at that point breaks them.
Use in-process hosting with IIS
With in-process hosting, the app runs inside the IIS worker process, and IIS manages that process through the Windows Process Activation Service (WAS). Out-of-process hosting proxies each request over the loopback adapter, the network interface that routes outgoing traffic back to the same machine, and that extra hop costs time. In-process hosting has been the default since ASP.NET Core 3.0.
Don’t assume ContentLength has a value
HttpRequest.ContentLength is null when the request has no body, so check for null before using it.
Where to start
If you only fix a few things, start with blocking calls and synchronous body reads, then page large result sets and add AsNoTracking() to read-only queries. After that, go through the HttpContext rules above. Those bugs often don’t show up until the app is under real load.
More info and resources
10 Best Practices to Secure ASP.NET Core MVC Web Applications | Syncfusion Blogs
ASP.NET Core Best Practices | Microsoft Learn
ASP.NET Core application architecture guidance (microsoft.com)
Overview of ASP.NET Core MVC | Microsoft Learn
