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

AsNoTracking()

Read-only API endpoints, reports, dropdowns

Eliminates change-tracker CPU and memory overhead

AsSplitQuery()

Complex queries with multiple .Include() collections

Prevents Cartesian explosion and network data duplication

EF.CompileQuery

High-frequency hot-path queries executed repeatedly

Bypasses LINQ-to-SQL translation overhead