Best practices

In this article

This article provides guidelines for maximizing performance and reliability of ASP.NET Core apps.

Cache aggressively

Caching is discussed in several parts of this article. For more information, see Overview of caching in ASP.NET Core.

Understand hot code paths

The term "hot code path" has come to mean many things to many different people.

Avoid blocking calls

Asynchronous APIs allow a small pool of threads to handle thousands of concurrent requests by not waiting on blocking calls.

A common performance problem in ASP.NET Core apps is blocking calls that could be asynchronous. Many synchronous blocking calls lead to Thread Pool starvation and degraded response times.

Do not block asynchronous execution by calling Task.Wait or Task<TResult>.Result. Do not acquire locks in common code paths. ASP.NET Core apps perform best when architected to run code in parallel. Do not call Task.Run and immediately await it. ASP.NET Core already runs app code on normal Thread Pool threads, so calling Task.Run only results in extra unnecessary Thread Pool scheduling. Even if the scheduled code would block a thread, Task.Run does not prevent that.

A profiler, such as PerfView, can be used to find threads frequently added to the Thread Pool. The Microsoft-Windows-DotNETRuntime/ThreadPoolWorkerThread/Start event indicates a thread added to the thread pool.

Return large collections across multiple smaller pages

In our series of articles on web performance, we look at how you can improve the performance of your website.

In this talk, we will be looking at how to return an exhaustive result and asynchronously populate batches of results to avoid locking server resources.

For more information on paging and limiting the number of returned records, see:

Return IEnumerable<T> or IAsyncEnumerable<T>

Returning IEnumerable<T> from an action results in synchronous collection iteration by the serializer. The result is the blocking of calls and a potential for thread pool starvation. To avoid synchronous enumeration, use ToListAsync before returning the enumerable.

Beginning with ASP.NET Core 3.0, IAsyncEnumerable can be used as an alternative to IEnumerable<T> that enumerates asynchronously. For more information, see Controller action return types.

Minimize large object allocations

The .NET Core garbage collector manages allocation and release of memory automatically in ASP.NET Core apps.

Recommendations:

Memory issues, such as the preceding, can be diagnosed by reviewing garbage collection (GC) stats in PerfView and examining:

For more information, see Garbage Collection and Performance.

Optimize data access and I/O

Interactions with a data store and other remote services are often the slowest parts of an ASP.NET Core app. Reading and writing data efficiently is critical for good performance.

Recommendations:

The following approaches may improve performance in high-scale apps:

In this paper, we show how to improve the performance of queries compiled on top of high-performance queries compiled on top of low-performance queries.

The best way to find out what is going wrong with a query is to look at its performance.

Pool HTTP connections with HttpClientFactory

Although HttpClient implements the IDisposable interface, it's designed for reuse. Closed HttpClient instances leave sockets open in the TIME_WAIT state for a short period of time. If a code path that creates and disposes of HttpClient objects is frequently used, the app may exhaust available sockets. HttpClientFactory was introduced in ASP.NET Core 2.1 as a solution to this problem. It handles pooling HTTP connections to optimize performance and reliability. For more information, see Use HttpClientFactory to implement resilient HTTP requests.

Recommendations:

Keep common code paths fast

You want all of your code to be fast. Frequently-called code paths are the most critical to optimize. These include:

Recommendations:

Complete long-running Tasks outside of HTTP requests

A request to an ASP.NET Core app can be handled by a controller or page model calling necessary services and returning an HTTP response.

Recommendations:

Minify client assets

ASP.NET Core apps with complex front-ends frequently serve many JavaScript, CSS, or image files. Performance of initial load requests can be improved by:

Recommendations:

Compress responses

An app's payload is the amount of data it sends to the web browser when it responds to a request.

Use the latest ASP.NET Core release

The current version of ASP.NET Core is .NET Core.

Minimize exceptions

Exceptions should be rare. Throwing and catching exceptions is slow relative to other code flow patterns. Because of this, exceptions shouldn't be used to control normal program flow.

Recommendations:

App diagnostic tools, such as Application Insights, can help to identify common exceptions in an app that may affect performance.

Avoid synchronous read or write on HttpRequest/HttpResponse body

All I/O in ASP.NET Core is asynchronous. Servers implement the Stream interface, which has both synchronous and asynchronous overloads. The asynchronous ones should be preferred to avoid blocking thread pool threads. Blocking threads can lead to thread pool starvation.

Do not do this: The following example uses the ReadToEnd. It blocks the current thread to wait for the result. This is an example of sync over async.

public class BadStreamReaderController : Controller
{
    [HttpGet("/contoso")]
    public ActionResult<ContosoData> Get()
    {
        var json = new StreamReader(Request.Body).ReadToEnd();

        return JsonSerializer.Deserialize<ContosoData>(json);
    }
}

In the preceding code, Get synchronously reads the entire HTTP request body into memory. If the client is slowly uploading, the app is doing sync over async. The app does sync over async because Kestrel does NOT support synchronous reads.

Do this: The following example uses ReadToEndAsync and does not block the thread while reading.

public class GoodStreamReaderController : Controller
{
    [HttpGet("/contoso")]
    public async Task<ActionResult<ContosoData>> Get()
    {
        var json = await new StreamReader(Request.Body).ReadToEndAsync();

        return JsonSerializer.Deserialize<ContosoData>(json);
    }

}

The preceding code asynchronously reads the entire HTTP request body into memory.

Warning If the request is large, reading the entire HTTP request body into memory could lead to an out of memory (OOM) condition. OOM can result in a Denial Of Service. For more information, see Avoid reading large request bodies or response bodies into memory in this article.

Do this: The following example is fully asynchronous using a non-buffered request body:

public class GoodStreamReaderController : Controller
{
    [HttpGet("/contoso")]
    public async Task<ActionResult<ContosoData>> Get()
    {
        return await JsonSerializer.DeserializeAsync<ContosoData>(Request.Body);
    }
}

The preceding code asynchronously de-serializes the request body into a C# object.

Prefer ReadFormAsync over Request.Form

Use HttpContext.Request.ReadFormAsync instead of HttpContext.Request.Form. HttpContext.Request.Form can be safely read only with the following conditions:

Do not do this: The following example uses HttpContext.Request.Form. HttpContext.Request.Form uses sync over async and can lead to thread pool starvation.

public class BadReadController : Controller
{
    [HttpPost("/form-body")]
    public IActionResult Post()
    {
        var form =  HttpContext.Request.Form;

        Process(form["id"], form["name"]);

        return Accepted();
    }

Do this: The following example uses HttpContext.Request.ReadFormAsync to read the form body asynchronously.

public class GoodReadController : Controller
{
    [HttpPost("/form-body")]
    public async Task<IActionResult> Post()
    {
       var form = await HttpContext.Request.ReadFormAsync();

        Process(form["id"], form["name"]);

        return Accepted();
    }

Avoid reading large request bodies or response bodies into memory

In .NET, every object allocation greater than or equal to 85,000 bytes ends up in the large object heap (LOH). Large objects are expensive in two ways:

This blog post describes the problem succinctly:

Storing a large request or response body into a single byte[] or string:

Working with a synchronous data processing API

When using a serializer/de-serializer that only supports synchronous reads and writes (for example, Json.NET):

Warning If the request is large, it could lead to an out of memory (OOM) condition. OOM can result in a Denial Of Service. For more information, see Avoid reading large request bodies or response bodies into memory in this article.

ASP.NET Core 3.0 uses System.Text.Json by default for JSON serialization. System.Text.Json:

Do not store IHttpContextAccessor.HttpContext in a field

The IHttpContextAccessor.HttpContext returns the HttpContext of the active request when accessed from the request thread. The IHttpContextAccessor.HttpContext should not be stored in a field or variable.

Do not do this: The following example stores the HttpContext in a field and then attempts to use it later.

public class MyBadType
{
    private readonly HttpContext _context;
    public MyBadType(IHttpContextAccessor accessor)
    {
        _context = accessor.HttpContext;
    }

    public void CheckAdmin()
    {
        if (!_context.User.IsInRole("admin"))
        {
            throw new UnauthorizedAccessException("The current user isn't an admin");
        }
    }
}

The preceding code frequently captures a null or incorrect HttpContext in the constructor.

Do this: The following example:

public class MyGoodType
{
    private readonly IHttpContextAccessor _accessor;
    public MyGoodType(IHttpContextAccessor accessor)
    {
        _accessor = accessor;
    }

    public void CheckAdmin()
    {
        var context = _accessor.HttpContext;
        if (context != null && !context.User.IsInRole("admin"))
        {
            throw new UnauthorizedAccessException("The current user isn't an admin");
        }
    }
}

Do not access HttpContext from multiple threads

HttpContext is not thread-safe. Accessing HttpContext from multiple threads in parallel can result in unexpected behavior such as hangs, crashes, and data corruption.

The following example makes three parallel requests and logs the incoming request path before and after the outgoing HTTP request.

public class AsyncBadSearchController : Controller
{       
    [HttpGet("/search")]
    public async Task<SearchResults> Get(string query)
    {
        var query1 = SearchAsync(SearchEngine.Google, query);
        var query2 = SearchAsync(SearchEngine.Bing, query);
        var query3 = SearchAsync(SearchEngine.DuckDuckGo, query);

        await Task.WhenAll(query1, query2, query3);

        var results1 = await query1;
        var results2 = await query2;
        var results3 = await query3;

        return SearchResults.Combine(results1, results2, results3);
    }       

    private async Task<SearchResults> SearchAsync(SearchEngine engine, string query)
    {
        var searchResults = _searchService.Empty();
        try
        {
            _logger.LogInformation("Starting search query from {path}.", 
                                    HttpContext.Request.Path);
            searchResults = _searchService.Search(engine, query);
            _logger.LogInformation("Finishing search query from {path}.", 
                                    HttpContext.Request.Path);
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Failed query from {path}", 
                             HttpContext.Request.Path);
        }

        return await searchResults;
    }

Do this: The following example copies all data from the incoming request before making the three parallel requests.

public class AsyncGoodSearchController : Controller
{       
    [HttpGet("/search")]
    public async Task<SearchResults> Get(string query)
    {
        string path = HttpContext.Request.Path;
        var query1 = SearchAsync(SearchEngine.Google, query,
                                 path);
        var query2 = SearchAsync(SearchEngine.Bing, query, path);
        var query3 = SearchAsync(SearchEngine.DuckDuckGo, query, path);

        await Task.WhenAll(query1, query2, query3);

        var results1 = await query1;
        var results2 = await query2;
        var results3 = await query3;

        return SearchResults.Combine(results1, results2, results3);
    }

    private async Task<SearchResults> SearchAsync(SearchEngine engine, string query,
                                                  string path)
    {
        var searchResults = _searchService.Empty();
        try
        {
            _logger.LogInformation("Starting search query from {path}.",
                                   path);
            searchResults = await _searchService.SearchAsync(engine, query);
            _logger.LogInformation("Finishing search query from {path}.", path);
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Failed query from {path}", path);
        }

        return await searchResults;
    }

Do not use the HttpContext after the request is complete

HttpContext is only valid as long as there is an active HTTP request in the ASP.NET Core pipeline. The entire ASP.NET Core pipeline is an asynchronous chain of delegates that executes every request. When the Task returned from this chain completes, the HttpContext is recycled.

Do not do this: The following example uses async void which makes the HTTP request complete when the first await is reached:

public class AsyncBadVoidController : Controller
{
    [HttpGet("/async")]
    public async void Get()
    {
        await Task.Delay(1000);

        // The following line will crash the process because of writing after the 
        // response has completed on a background thread. Notice async void Get()

        await Response.WriteAsync("Hello World");
    }
}

Do this: The following example returns a Task to the framework, so the HTTP request doesn't complete until the action completes.

public class AsyncGoodTaskController : Controller
{
    [HttpGet("/async")]
    public async Task Get()
    {
        await Task.Delay(1000);

        await Response.WriteAsync("Hello World");
    }
}

Do not capture the HttpContext in background threads

Do not do this: The following example shows a closure is capturing the HttpContext from the Controller property. This is a bad practice because the work item could:

[HttpGet("/fire-and-forget-1")]
public IActionResult BadFireAndForget()
{
    _ = Task.Run(async () =>
    {
        await Task.Delay(1000);

        var path = HttpContext.Request.Path;
        Log(path);
    });

    return Accepted();
}

Do this: The following example:

[HttpGet("/fire-and-forget-3")]
public IActionResult GoodFireAndForget()
{
    string path = HttpContext.Request.Path;
    _ = Task.Run(async () =>
    {
        await Task.Delay(1000);

        Log(path);
    });

    return Accepted();
}

Background tasks should be implemented as hosted services. For more information, see Background tasks with hosted services.

Do not capture services injected into the controllers on background threads

Do not do this: The following example shows a closure that is capturing the DbContext from the Controller action parameter. This is a bad practice. The work item could run outside of the request scope. The ContosoDbContext is scoped to the request, resulting in an ObjectDisposedException.

[HttpGet("/fire-and-forget-1")]
public IActionResult FireAndForget1([FromServices]ContosoDbContext context)
{
    _ = Task.Run(async () =>
    {
        await Task.Delay(1000);

        context.Contoso.Add(new Contoso());
        await context.SaveChangesAsync();
    });

    return Accepted();
}

Do this: The following example:

[HttpGet("/fire-and-forget-3")]
public IActionResult FireAndForget3([FromServices]IServiceScopeFactory 
                                    serviceScopeFactory)
{
    _ = Task.Run(async () =>
    {
        await Task.Delay(1000);

        await using (var scope = serviceScopeFactory.CreateAsyncScope())
        {
            var context = scope.ServiceProvider.GetRequiredService<ContosoDbContext>();

            context.Contoso.Add(new Contoso());

            await context.SaveChangesAsync();                                        
        }
    });

    return Accepted();
}

The following highlighted code:

[HttpGet("/fire-and-forget-3")]
public IActionResult FireAndForget3([FromServices]IServiceScopeFactory 
                                    serviceScopeFactory)
{
    _ = Task.Run(async () =>
    {
        await Task.Delay(1000);

        await using (var scope = serviceScopeFactory.CreateAsyncScope())
        {
            var context = scope.ServiceProvider.GetRequiredService<ContosoDbContext>();

            context.Contoso.Add(new Contoso());

            await context.SaveChangesAsync();                                        
        }
    });

    return Accepted();
}

Do not modify the status code or headers after the response body has started

ASP.NET Core does not buffer the HTTP response body. The first time the response is written:

Do not do this: The following code tries to add response headers after the response has already started:

app.Use(async (context, next) =>
{
    await next();

    context.Response.Headers["test"] = "test value";
});

In the preceding code, context.Response.Headers["test"] = "test value"; will throw an exception if next() has written to the response.

Do this: The following example checks if the HTTP response has started before modifying the headers.

app.Use(async (context, next) =>
{
    await next();

    if (!context.Response.HasStarted)
    {
        context.Response.Headers["test"] = "test value";
    }
});

Do this: The following example uses HttpResponse.OnStarting to set the headers before the response headers are flushed to the client.

Checking if the response has not started allows registering a callback that will be invoked just before response headers are written. Checking if the response has not started:

app.Use(async (context, next) =>
{
    context.Response.OnStarting(() =>
    {
        context.Response.Headers["someheader"] = "somevalue";
        return Task.CompletedTask;
    });

    await next();
});

Do not call next() if you have already started writing to the response body

Components only expect to be called if it's possible for them to handle and manipulate the response.

Use In-process hosting with IIS

Microsoft has announced a new way to host ASP.NET Core applications on Windows machines.

Projects default to the in-process hosting model in ASP.NET Core 3.0 and later.

For more information, see Host ASP.NET Core on Windows with IIS

Don't assume that HttpRequest.ContentLength is not null

HttpRequest.ContentLength is null if the Content-Length header is not received. Null in that case means the length of the request body is not known; it doesn't mean the length is zero. Because all comparisons with null (except ==) return false, the comparison Request.ContentLength > 1024, for example, might return false when the request body size is more than 1024. Not knowing this can lead to security holes in apps. You might think you're guarding against too-large requests when you aren't.

For more information, see this StackOverflow answer.

Reliable web app patterns

The Reliable Web App Pattern for.NET YouTube videos and article for guidance on creating a modern, reliable, performant, and scalable ASP.NET Core app, whether from scratch or an existing app.

Ref: ASP.NET Core Best Practices