Object Relational Mappers (ORMs) can help increase our productivity. The ORM pattern can handle the integration between our C# models and the database by providing an abstraction that limits our need to understand the underlying data-access mechanisms (not an excuse to be ignorant).

The concept can be helpful when starting development of our applications, but we always want to stay vigilant for low-effort optimizations that can help our users.

In this post, we’ll be seeing a simple technique that can yield up to 400% speed improvements and cut our memory utilization by 100% when using Entity Framework Core (EF Core).

Object Tracking

The general goal of an ORM is to help us manage changes between our in-memory state and the state stored in our database. This feature of ORMs is commonly known as Object Tracking, and each ORM can choose to do this slightly differently. We may also hear folks mention the change tracker when speaking of Entity Framework Core.

If an entity is tracked, any changes detected in the entity will be persisted to the database during SaveChanges() Microsoft

In regards to Entity Framework Core, by default, entity tracking occurs on all entities that are part of our data context. As we may have guessed, nothing happens for free. There are memory and processing costs to tracking entities that pass through our data context.

The Issue

Let’s start with an underlying assumption. Most applications are read heavy, which means we retrieve more data from our databases than we write. If we accept that fact, that means we are wasting resources on unnecessary object tracking.

The Solution

Entity Framework Core developers understand there is a cost to object tracking, and have allowed us to disable it in an ad hoc fashion. We may have seen the use of the AsNoTracking construct in our codebase.

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

The call to AsNoTracking does two things of note:

  1. It tells Entity Framework Core not to track the entities resulting from our query.
  2. It keeps Entity Framework Core from using memory to cache any of the results.

We can also control this behavior at the context level.

context
    .ChangeTracker
    .QueryTrackingBehavior = QueryTrackingBehavior.NoTracking; 

The Approach

There are several ways to turn off object tracking, and we saw a way to ask EF Core to stop tracking explicitly for each query. While the approach works, it can be fraught with human error. Let’s take a look at an example using SQLite and Entity Framework Core. For this example, we’ve seeded 1000 records into our Item table.

public class ShopDbContext : DbContext
{
    public DbSet<Item> Items { get; set; }
    
    protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
    {
        optionsBuilder
            .UseSqlite("Data Source=example.db");
    }
}

public class Item
{
    public int Id { get; set; }
    public string Name { get; set; }
    public string Category { get; set; }
    public decimal Price { get; set; }
}

We can inherit from our ShopDbContext and create a LightShopDbContext that turns object tracking off by default.

public sealed class LightShopDbContext : ShopDbContext
{
    public LightShopDbContext()
    {
        // light sessions only
        // this will improve performance with no tracking
        ChangeTracker.QueryTrackingBehavior = 
            QueryTrackingBehavior.NoTracking;
    }
    
    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        // leave the heavy lifting to the base class
    }
}

The main benefit of this approach is we know we are explicitly dealing with a no tracking context. There is no confusion in our codebase of what is happening and what the expectations are. Let’s take a look at a sample usage utilizing Razor Pages.

public async Task OnGet([FromServices] LightShopDbContext db)
{
    Results = await db
        .Items
        .Where(x => (double) x.Price > 100)
        .ToListAsync();
}

We can test the difference between having object tracking turned on and off. Note, we throw away the first execution because it is an outlier due to start-up costs.

public async Task OnGet()
{
    var services = HttpContext.RequestServices;
    Light = await ExecuteAsync(
        () => services.GetService(typeof(LightShopDbContext)) as ShopDbContext,
        NumberOfExecutions
    );
    
    Heavy = await ExecuteAsync(
        () => services.GetService(typeof(ShopDbContext)) as ShopDbContext,
        NumberOfExecutions
    );
}

public static async Task<List<QueryResult>> ExecuteAsync(
    Func<ShopDbContext> databaseFactory, 
    int numOfExecutions = 10)
{
    var queryResults = new List<QueryResult>();
    var random = new Random();

    // the first execution is an outlier
    for (var i = 0; i < numOfExecutions + 1; i++)
    {
        // create a database each time
        // closer to what happens in a real-world app
        await using var db = databaseFactory();
        var stopwatch = Stopwatch.StartNew();
        var skip = random.Next(1, 100);

        var items =
            await db.Items
                .Where(x => (double)x.Price > 0)
                .Skip(skip)
                .Take(100)
                .ToListAsync();

        stopwatch.Stop();
        queryResults.Add(new QueryResult
        {
            Elapsed = stopwatch.Elapsed
        });
    }

    return queryResults.Skip(1).ToList();
}

When we execute the request, we can see the LightShopDbContext on average is performing faster than the tracked ShopDbContext.

EF Core performance averages

For this test, we registered each context as Transient to be able to mimic the behavior of a new context per request.

public void ConfigureServices(IServiceCollection services)
{
    services.AddRazorPages();
    
    services.AddTransient<ShopDbContext>();
    services.AddTransient<LightShopDbContext>();
}

Running the queries in a loop with a Scoped instance would have resulted in the ShopDbContext being better since each query would have likely pulled its results from the in-memory cache. It is highly recommended you register a DbContext as Scoped to take advantage of caching and tracking when needed.

Query Benchmarks

We may be saying, “well, this isn’t very scientific.” We would be right!

I wrote a Benchmark.NET harness to see the performance and memory profile of this method under a rigorous test.

[MemoryDiagnoser]
[SimpleJob(RuntimeMoniker.NetCoreApp31)]
public class NoTrackingBenchmarks
{
    [Params(1, 10, 100)]
    public int NumberOfExecutions { get; set; }

    [GlobalSetup]
    public void Setup()
    {
        using var db = new ShopDbContext();
        db.Database.EnsureCreated();
    }

    [Benchmark]
    public void Light()
    {
        IndexModel.ExecuteAsync(
            () => new LightShopDbContext(),
            NumberOfExecutions);
    }

    [Benchmark]
    public void Heavy()
    {
        IndexModel.ExecuteAsync(
            () => new ShopDbContext(), 
            NumberOfExecutions);
    }
}

We get the following results after running the benchmark harness.

We can see the query performance is faster with the LightShopDbContext and that it has much less memory allocation. We get this because we are no longer tracking our EF Core query results.

Method NumberOfExecutions Mean Error StdDev Median Gen 0 Gen 1 Gen 2 Allocated
Light 1 7.057 ms 1.157 ms 3.226 ms 5.896 ms - - - 167.22 KB
Heavy 1 12.488 ms 2.013 ms 5.935 ms 15.364 ms 140.6250 - - 317.04 KB
Light 10 27.664 ms 1.343 ms 3.721 ms 27.155 ms 428.5714 - - 879.29 KB
Heavy 10 44.481 ms 1.976 ms 5.308 ms 43.291 ms - - - 1742.6 KB
Light 100 233.770 ms 4.599 ms 10.475 ms 231.551 ms 3000.0000 - - 8069.05 KB
Heavy 100 265.456 ms 5.247 ms 9.461 ms 264.513 ms 7000.0000 - - 15987.02 KB

Awesome!

Conclusion

Object tracking is an essential feature of any ORM, but more of the applications we write are read-heavy, meaning we don’t see the benefit unless we are editing our models. Disabling this feature can yield performance and memory gains that will make all our stakeholders happy. Remember, this approach will vary based on other factors.

Explicitly creating a no-tracking context will make sure there is no confusion in our codebase. Even if we don’t end up taking the no-tracking context approach, we must evaluate our query executions for the proper use of AsNoTracking when working with EF Core.

I hope you found this post enlightening and helpful. You can download the solution at this GitHub repository.