1. Use Async/Await

Asynchronous Programming helps improve the overall efficiency while dealing with functions that can take some time to finish computing. During such function executions, the complete application may seem to be frozen to the end-user. This results in a bad user experience. In such cases, we use async methods to free the main thread.

public async Task GetEmployee(int empId)
{
       var employee = await new EmployeeService().GetAsync(empId);

        if (employee != null)
        {
            var employeeInfo = new
            {
                id = employee.Id,
                code = employee.Code,
                text = employee.FullName
            };
            return Json(employeeInfo);
        }
        else
        {
            return Json(new { });
        }
    }

ASP.NET Core apps should be designed to process many requests simultaneously. Asynchronous APIs allow a small pool of threads to handle thousands of concurrent requests by not waiting on blocking calls. Rather than waiting on a long-running synchronous task to complete, the thread can work on another request.

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.

2. Caching large objects that are frequently used

Generally, we don’t need to worry about memory release or allocation as the .NET Core garbage collector manages the allocation and release of memory automatically. But cleaning up unreferenced objects takes CPU time, so developers should minimize allocating objects. Garbage collection is especially expensive on large objects (> 85 K bytes). Large objects are stored on the large object heap and require a full (generation 2) garbage collection to clean up. Unlike generation 0 and generation 1 collections, a generation 2 collection requires a temporary suspension of app execution. Frequent allocation and de-allocation of large objects can cause inconsistent performance. In this case, caching large objects prevents expensive allocations. caching frequently accessed data retrieved from a database or remote service if slightly out-of-date data is acceptable. Depending on the scenario, use a MemoryCache or a DistributedCache such as Redis.

Besides, do not allocate many, short-lived large objects on frequently used methods.

Response caching is also a way of improving performance which reduces the number of requests a client or proxy makes to a web server. Response caching also reduces the amount of work the web server performs to generate a response.

3. Use Pagination to return large collections

A webpage shouldn’t load large amounts of data all at once. When returning a collection of objects, consider whether it could lead to performance issues. Determine if the design could produce the following poor outcomes:

  • High CPU or high memory consumption

  • Thread pool starvation

  • Slow response times

  • Frequent garbage collection

Do add pagination to mitigate the preceding scenarios.

4. Use AsNoTracking() while reading only

By default, queries that return entity types are tracked in Entity Framework Core. When we load records from a database via LINQ-to-Entities queries, we will be processing them and update them back to the database. For this purpose, entities are tracked.

Do use no-tracking queries when accessing data for read-only purposes. EF Core can return the results of no-tracking queries more efficiently. When we are performing only read operations, we won’t make any updates back to the database, but entities will assume that we are going to make updates back to the database and will process them accordingly. So, we can use AsNoTracking() to restrict entities from assuming and processing, thus reducing the amount of memory that entities will track.

var blogs = context.Blogs
    .AsNoTracking()
    .ToList();

You can also change the default tracking behavior at the context instance level:

context.ChangeTracker.QueryTrackingBehavior = QueryTrackingBehavior.NoTracking;

var blogs = context.Blogs.ToList();

5. Review your data access and I/O

Data access and I/O are one of the most important factors for optimizing performance. If your system performs slow, review your I/O operations with the following criteria:

  • Ensure all data access APIs are asynchronous.

  • Do not retrieve more data than is necessary. Write queries to return just the data that’s necessary for the current HTTP request.

  • Minimize network round trips. The goal is to retrieve the required data in a single call rather than several calls.

  • Do filter and aggregate LINQ queries (with .Where, .Select, or .Sum statements, for example) so that the filtering is performed by the database.

  • Query caching and parameterization

    • When EF receives a LINQ query tree for execution, it must first “compile” that tree, e.g. produces SQL from it. Because this task is a heavy process, EF caches queries by the query tree shape, so that queries with the same structure reuse internally-cached compilation outputs. This caching ensures that executing the same LINQ query multiple times is very fast, even if parameter values differ.

      Consider the following two queries:

      var post1 = context.Posts.FirstOrDefault(p => p.Title == "post1");
      var post2 = context.Posts.FirstOrDefault(p => p.Title == "post2");
      

      Since the expression trees contain different constants, the expression tree differs and each of these queries will be compiled separately by EF Core. In addition, each query produces a slightly different SQL command:

      SELECT TOP(1) [b].[Id], [b].[Name]
      FROM [Blogs] AS [b]
      WHERE [b].[Name] = N'blog1'
      
      SELECT TOP(1) [b].[Id], [b].[Name]
      FROM [Blogs] AS [b]
      WHERE [b].[Name] = N'blog2'
      

      Because the SQL differs, your database server will likely also need to produce a query plan for both queries, rather than reusing the same plan.

      A small modification to your queries can change things considerably:

      var postTitle = "post1";
      var post1 = context.Posts.FirstOrDefault(p => p.Title == postTitle);
      postTitle = "post2";
      var post2 = context.Posts.FirstOrDefault(p => p.Title == postTitle);
      

      Since the blog name is now parameterized, both queries have the same tree shape, and EF only needs to be compiled once. The SQL produced is also parameterized, allowing the database to reuse the same query plan:

      SELECT TOP(1) [b].[Id], [b].[Name]
      FROM [Blogs] AS [b]
      WHERE [b].[Name] = @__blogName_0
      

      Note that there is no need to parameterize every query: it’s perfectly fine to have some queries with constants, and indeed, databases (and EF) can sometimes perform certain optimization around constants which aren’t possible when the query is parameterized.

  • Do not use projection queries on collections, which can result in executing “N + 1” SQL queries.

    As an example, the following query normally gets translated into one query for Customers, plus N (where “N” is the number of customers returned) separate queries for Orders:

    var query = context.Customers.Select(
        c => c.Orders.Where(o => o.Amount  > 100).Select(o => o.Amount));
    

    You can optimize it by including ToList() in the right place, you indicate that buffering is appropriate for the Orders:

    var query = context.Customers.Select(
        c => c.Orders.Where(o => o.Amount  > 100).Select(o => o.Amount).ToList());
    

    Note that this query will be translated to only two SQL queries: One for Customers and the next one for Orders.