While Entity Framework Core (EF Core) makes database interactions seamless and developer-friendly, default behaviors can introduce hidden performance bottlenecks at scale. In high-throughput APIs or data-heavy workloads, leveraging No-Tracking Queries, Split Queries, and Compiled Queries can drastically reduce memory overhead, prevent Cartesian explosions, and speed up query execution.
1. No-Tracking Queries (AsNoTracking)
The Problem
By default, whenever you query entities using EF Core, the change tracker monitors them. This means EF Core keeps a snapshot of each entity in memory to automatically detect changes when you eventually call SaveChanges(). For read-only operations (like filling a dashboard, API GET requests, or reporting), this change-tracking overhead wastes both CPU cycles and memory.
The Solution
Use .AsNoTracking() for any query where you only intend to read data and never call SaveChanges().
C#
// Without tracking overhead (significantly faster for reads)
var activeUsers = await _context.Users
.Where(u => u.IsActive)
.AsNoTracking()
.ToListAsync();
Bonus Tip: If your read-only query contains object graph cycles or you need to ensure entity instance identity is preserved without tracking updates, use
AsNoTrackingWithIdentityResolution().
2. Split Queries (AsSplitQuery)
The Problem
When you load an entity and include multiple related collections using .Include(), EF Core generates a single SQL query with a JOIN. If an order has 5 order lines and 3 shipping status logs, the database joins these tables, returning a Cartesian product (5 * 3 = 15 duplicated rows of order data transferred across the network). For large graphs, this causes exponential data duplication, bloated memory usage, and severe performance degradation.
The Solution
Use .AsSplitQuery() to instruct EF Core to execute multiple SQL queries—one for the principal query and separate queries for each included collection—eliminating data duplication entirely.
C#
var orders = await _context.Orders
.Where(o => o.Status == "Pending")
.Include(o => o.OrderItems)
.Include(o => o.ShipmentLogs)
.AsSplitQuery() // Fetches main query and collections in separate roundtrips
.ToListAsync();
3. Compiled Queries (EF.CompileQuery)
The Problem
Every time you execute a LINQ query, EF Core goes through a query-translation pipeline: it parses your LINQ expression tree, translates it into an abstract syntax tree, and compiles it into an internal execution plan. For queries executed thousands of times on high-traffic hot paths (such as authentication lookups or real-time ticker feeds), this translation overhead adds unnecessary latency.
The Solution
Use EF.CompileQuery (or EF.CompileAsyncQuery) to pre-compile the query delegate once at startup, bypassing translation overhead on subsequent executions.
C#
public static class CompiledQueries
{
// Define a compiled query statically
public static readonly Func<AppDbContext, int, Task<User?>> GetUserByIdQuery =
EF.CompileAsyncQuery((AppDbContext context, int id) =>
context.Users
.Include(u => u.Profile)
.AsNoTracking()
.FirstOrDefault(u => u.Id == id));
}
// Usage inside your controller or service:
var user = await CompiledQueries.GetUserByIdQuery(_context, userId);
Summary Checklist for Performance Tuning
Optimization Technique |
Best Used For |
Primary Benefit |
|
Read-only API endpoints, reports, dropdowns |
Eliminates change-tracker CPU and memory overhead |
|
Complex queries with multiple |
Prevents Cartesian explosion and network data duplication |
|
High-frequency hot-path queries executed repeatedly |
Bypasses LINQ-to-SQL translation overhead |

Join the conversation! Your thoughts help the community grow.