In Entity Framework Core (EF Core), query performance and memory management are heavily governed by one underlying mechanism: Change Tracking.
While EF Core's default tracking behavior makes data mutations seamless, applying it blindly across read-only queries can introduce significant memory and CPU overhead. Understanding how the ChangeTracker operates under the hood—and knowing when to bypass it using AsNoTracking() or AsNoTrackingWithIdentityResolution()—is essential for building high-performance .NET applications.
1. How Default Change Tracking Works
When EF Core executes a standard LINQ query, it does far more than map SQL result sets to C# objects. It engages a complex tracking infrastructure through the DbContext instance.
// Standard tracking query
var user = await dbContext.Users.FirstOrDefaultAsync(u => u.Id == 1);
The Query Execution Lifecycle
When executing the code above, EF Core carries out four sequential operations:
SQL Execution: Sends the generated SQL SELECT statement to the database server.
Object Materialization: Instantiates the C# entity class (User) and populates its properties from the SQL data reader.
Identity Resolution: Checks the DbContext's internal Identity Map. If an entity with the same primary key is already being tracked, EF Core discards the newly materialized instance and returns the existing tracked reference.
Snapshot Creation: Creates an internal snapshot copy of the entity's initial values inside the ChangeTracker.
What Happens During SaveChangesAsync()?
When SaveChangesAsync() is called, EF Core initiates a process known as Detect Changes. It scans all tracked entities and compares their current property values against the snapshots taken during Step 4.
var user = await dbContext.Users.FirstOrDefaultAsync(u => u.Id == 1);
user.Email = "[email protected]"; // Property updated
// DetectChanges compares state to snapshot -> generates UPDATE SQL statement
await dbContext.SaveChangesAsync();
The Cost of Tracking
Double Memory Footprint: Every tracked entity requires memory for both the active instance and its hidden initial snapshot.
CPU Overhead: DetectChanges() must run a property-by-property equality comparison across every tracked object in memory before generating SQL.
2. Bypassing the Change Tracker with AsNoTracking()
When fetching data strictly for read-only operations (such as populating a UI view or returning data from a REST API GET endpoint), change tracking is unnecessary.
var user = await dbContext.Users
.AsNoTracking()
.FirstOrDefaultAsync(u => u.Id == 1);
Under the Hood Mechanics
When .AsNoTracking() is appended to a query, EF Core alters its execution pipeline:
No Snapshots: Objects are materialized and returned immediately without making snapshot copies.
No DbContext Registration: The ChangeTracker remains unaware of the object's existence.
Faster Garbage Collection: Memory overhead is halved, allowing allocated memory to be reclaimed faster by the .NET Garbage Collector.
The Trade-Off: Identity Resolution Loss
In AsNoTracking() queries involving .Include() joins, EF Core bypasses the Identity Map entirely. This can lead to duplicate object instances in memory.
var orders = await dbContext.Orders
.AsNoTracking()
.Include(o => o.Customer)
.ToListAsync();
If Order 1 and Order 2 belong to Customer A, standard tracking would map both orders to a single shared Customer instance in RAM. With .AsNoTracking(), EF Core creates two separate Customer instances in memory with identical values.
3. The Middle Ground: AsNoTrackingWithIdentityResolution()
To solve object duplication in complex read-only queries with multiple joins, EF Core introduced AsNoTrackingWithIdentityResolution().
var orders = await dbContext.Orders
.AsNoTrackingWithIdentityResolution()
.Include(o => o.Customer)
.ToListAsync();
No Change Tracking: Entities are not snapshotted or monitored for SaveChangesAsync().
Query-Scoped Identity Map: EF Core maintains a temporary lookup table only for the duration of the query execution to ensure shared child entities reuse the same C# memory instance.
4. Feature Comparison Matrix
| Feature | Default Tracking | AsNoTracking() | AsNoTrackingWithIdentityResolution() |
|---|
| Snapshot Generation | Yes | No | No |
| Tracked by DbContext | Yes | No | No |
| SaveChangesAsync() Support | Automatic | Manual Attach Required | Manual Attach Required |
| Identity Resolution Scope | DbContext Lifetime | None (Duplicates allowed) | Single Query Execution |
| Memory Consumption | Highest | Lowest | Low |
| Execution Speed | Slower | Fastest | Fast |
5. Architectural Best Practices
Rule 1: Default to No-Tracking for API Endpoints
All query handlers in CQRS patterns, REST GET controllers, and gRPC read services should apply .AsNoTracking().
Rule 2: Set Global No-Tracking for Read-Heavy Applications
For applications where updates are rare compared to reads, configure no-tracking as the global default in Program.cs:
builder.Services.AddDbContext<AppDbContext>(options =>
options.UseSqlServer(connectionString)
.UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking));
When an update is required, explicitly opt back into tracking using .AsTracking():
var user = await dbContext.Users
.AsTracking()
.FirstOrDefaultAsync(u => u.Id == id);
Rule 3: Use Manual Attaching for Disconnected Scenarios
In web applications, entities often travel across network boundaries (e.g., received via JSON payload in a POST request). Instead of querying the database with tracking just to update a record, attach the incoming object directly:
public async Task UpdateUserAsync(User disconnectedUser)
{
// Attaches the entity and marks all properties as modified
dbContext.Users.Update(disconnectedUser);
await dbContext.SaveChangesAsync();
}
Summary
Choosing the right tracking strategy is one of the most effective knobs for optimizing EF Core applications. By reserve-tracking solely for mutations and applying .AsNoTracking() or .AsNoTrackingWithIdentityResolution() across all read operations, applications achieve lower latency, reduced memory usage, and predictable scaling.