Introduction
Entity Framework Core (EF Core) is a powerful Object-Relational Mapper (ORM), but default configurations can introduce performance bottlenecks under high database loads. Two of the most effective ways to scale EF Core read operations are disabling change tracking (AsNoTracking) and utilizing Compiled Queries to bypass expression tree parsing overhead on repetitive database calls.
Step 1: Understanding AsNoTracking for Read-Only Workloads
By default, EF Core tracks entity instances in its change tracker so that changes can be saved back via SaveChanges(). For high-throughput read-only queries (like API dashboards or reporting loops), tracking introduces unnecessary memory allocation and CPU overhead.
C#
// Standard tracked query (consumes memory for change tracking state)
var trackedProducts = await _context.Products
.Where(p => p.IsActive)
.ToListAsync();
// High-performance read-only query (bypasses change tracker entirely)
var optimizedProducts = await _context.Products
.AsNoTracking()
.Where(p => p.IsActive)
.ToListAsync();
Step 2: Implementing Compiled Queries for Repeated Execution
When a LINQ query is executed normally, EF Core translates the LINQ expression tree into SQL every single time. For hot-path queries called thousands of times per minute, Compiled Queries parse the expression tree once at startup and cache the compiled delegate for rapid execution.
Create a static class to house your compiled queries.
C#
using Microsoft.EntityFrameworkCore;
public static class ProductQueries
{
// Compile a query that takes an ApplicationDbContext and a category ID, returning a list of products
public static readonly Func<AppDbContext, string, Task<IEnumerable<Product>>> GetProductsByCategory =
EF.CompileAsyncQuery((AppDbContext context, string category) =>
context.Products
.AsNoTracking()
.Where(p => p.Category == category && p.IsActive)
);
// Compile a query that takes an ApplicationDbContext and an integer ID, returning a single product
public static readonly Func<AppDbContext, int, Task<Product?>> GetProductById =
EF.CompileAsyncQuery((AppDbContext context, int id) =>
context.Products
.AsNoTracking()
.FirstOrDefault(p => p.Id == id)
);
}
Step 3: Invoking Compiled Queries inside Repositories or Services
Inject your database context as usual and invoke the compiled query delegates directly.
C#
public class ProductService
{
private readonly AppDbContext _context;
public ProductService(AppDbContext context)
{
_context = context;
}
public async Task<Product?> GetDetailsAsync(int id)
{
// Invoke the cached compiled query delegate
return await ProductQueries.GetProductById(_context, id);
}
public async Task<IEnumerable<Product>> GetByCategoryAsync(string category)
{
// Invoke the cached compiled query delegate for high throughput
return await ProductQueries.GetProductsByCategory(_context, category);
}
}
Step 4: Performance Benchmarking Strategy
When combining AsNoTracking() with compiled queries on hot database routes, you achieve:
Zero Change Tracker Allocations: Reduces Garbage Collection (GC) pressure under heavy concurrent loads.
Eliminated Expression Parsing Overhead: Bypasses string translation steps during query execution.
Optimized Network and Memory Footprint: Returns lightweight, read-only data payloads instantly.
Summary
Using AsNoTracking() for read-only operations and Compiled Queries for frequently executed LINQ expressions significantly improves EF Core read performance by reducing change tracking overhead, minimizing query compilation costs, and optimizing memory usage for high-throughput applications.