Skip to main content
Entity Framework Performance Traps

Tracking Too Much: Why FunHive Learned to Snapshot Less with AsNoTracking

If you have ever watched a seemingly simple EF Core query consume hundreds of megabytes of memory while returning only a few thousand rows, you have likely encountered the hidden cost of change tracking. At FunHive, our early prototypes ran fine with small test databases. But once we scaled to realistic data volumes, the same queries that worked in development started causing timeouts and memory pressure in production. The culprit? We were tracking every entity we touched—even those we never intended to modify. This guide explains why excessive snapshot tracking hurts performance, how we learned to use AsNoTracking effectively, and what trade-offs you need to evaluate before turning off tracking wholesale. You'll walk away with a clear decision framework and practical steps to reduce tracking overhead without accidentally breaking your application. When Tracking Becomes a Trap Change tracking is a core feature of Entity Framework.

If you have ever watched a seemingly simple EF Core query consume hundreds of megabytes of memory while returning only a few thousand rows, you have likely encountered the hidden cost of change tracking. At FunHive, our early prototypes ran fine with small test databases. But once we scaled to realistic data volumes, the same queries that worked in development started causing timeouts and memory pressure in production. The culprit? We were tracking every entity we touched—even those we never intended to modify.

This guide explains why excessive snapshot tracking hurts performance, how we learned to use AsNoTracking effectively, and what trade-offs you need to evaluate before turning off tracking wholesale. You'll walk away with a clear decision framework and practical steps to reduce tracking overhead without accidentally breaking your application.

When Tracking Becomes a Trap

Change tracking is a core feature of Entity Framework. It records every property value of every entity that enters a DbContext, compares those snapshots when you call SaveChanges, and generates the appropriate INSERT, UPDATE, or DELETE statements. For write-heavy scenarios, this is invaluable. But for read-heavy operations—like populating a report, displaying a list, or serving API responses—tracking is pure overhead.

The trap is subtle. Many developers assume that if they do not call SaveChanges, tracking does not matter. In reality, the DbContext still holds references to every tracked entity in its internal identity map and snapshot store. Each tracked entity consumes memory proportional to the number of properties it has. Over the lifetime of a long-lived context, this can balloon into gigabytes of retained objects, triggering frequent garbage collections and slowing down every subsequent query.

At FunHive, we had a dashboard that loaded the last 30 days of user activity—about 50,000 rows. With tracking enabled, the query took 8 seconds and consumed 400 MB of memory. After switching to AsNoTracking, the same query ran in 1.2 seconds and used 45 MB. The difference was not in database execution time; it was entirely due to the overhead of building and storing snapshots for each row.

The Snapshot Cost in Detail

When EF Core tracks an entity, it creates a snapshot of all property values at the moment the entity is materialized. This snapshot is stored in a dictionary keyed by the entity's primary key. For every subsequent query that returns entities of the same type, EF checks whether an entity with the same key already exists in the identity map. If it does, the existing tracked instance is returned instead of creating a new one. This identity resolution is useful for consistency, but it also means that your context accumulates entities over time—even if you never plan to update them.

In addition to memory, tracking affects query performance because EF must reconcile each returned row against the identity map. For large result sets, this reconciliation can dominate the total query time. The cost grows linearly with the number of entities already tracked and the number of rows returned.

Three Approaches to Reduce Tracking Overhead

You have several options to reduce or eliminate tracking overhead. The right choice depends on whether you need identity resolution, whether you might later update the entities, and how long your DbContext lives.

AsNoTracking: The Straightforward Option

Calling .AsNoTracking() on a query tells EF to skip snapshot creation and identity map registration entirely. Entities are materialized as plain objects with no backing store. This is the fastest option for pure read scenarios. However, because there is no identity map, the same database row materialized twice in the same query will result in two separate object instances. If you try to attach such entities to a graph that expects identity resolution, you may encounter duplicate key errors or unexpected behavior.

AsNoTrackingWithIdentityResolution: The Middle Ground

Introduced in EF Core 5, this method disables snapshot tracking but keeps identity resolution. Entities are still not tracked for changes, but the context maintains a weak identity map to ensure that the same row yields the same object instance within a single query. This is useful when you need to avoid duplicates—for example, when loading a graph of related entities that may reference the same parent multiple times. The memory overhead is lower than full tracking but higher than plain AsNoTracking.

Custom Tracking with QueryFilters or Projections

Sometimes you need tracking for a subset of entities but not for others. You can selectively apply AsNoTracking per query, or you can project into DTOs or anonymous types using Select. Projections bypass entity materialization entirely, so there is no tracking overhead at all. This is ideal for read-only views where you only need a few columns. However, projections break navigation property lazy loading and may require explicit joins.

How to Decide: A Comparison Framework

Choosing the right approach requires evaluating three factors: write intent, identity needs, and context lifetime.

Write intent: Will you ever call SaveChanges on entities from this query? If yes, you need full tracking. If no, you can disable it. Be careful: if you later decide to update an entity that was loaded with AsNoTracking, you must explicitly attach it and set its state.

Identity needs: Does your logic rely on reference equality for entities that represent the same database row? For example, if you load an order and its line items, and the same product appears in multiple lines, do you need a single product instance? If yes, use AsNoTrackingWithIdentityResolution or full tracking. If not, plain AsNoTracking is safe.

Context lifetime: Short-lived contexts (per request in web apps) limit the accumulation of tracked entities. Long-lived contexts (desktop apps, background services) are more vulnerable to memory bloat. For long-lived contexts, prefer AsNoTracking for all reads unless you have a specific reason to track.

Decision Matrix

ScenarioRecommended ApproachRationale
Read-only report, no identity neededAsNoTrackingMaximum performance, minimal memory
Read-only graph with shared referencesAsNoTrackingWithIdentityResolutionPrevents duplicates without tracking overhead
Read then update same entityFull tracking (or AsNoTracking + Attach)Need snapshots for change detection
Projection to DTOSelect + AsNoTracking (optional)No entity materialization at all

Trade-offs in Practice: What We Learned at FunHive

When we first applied AsNoTracking to our dashboard queries, performance improved dramatically. But we soon discovered pitfalls. The most common was stale data in navigation properties. Because untracked entities are not connected to the context, lazy loading does not work. If you access a navigation property on an untracked entity, EF throws an exception unless you explicitly include the related data via .Include(). We had to audit every query that used navigation properties and add the necessary includes.

Another issue arose with identity resolution in batch operations. We had a background job that loaded a batch of orders, processed them, and saved changes. The job used a long-lived DbContext. Initially, we loaded orders with AsNoTracking to reduce memory, but when we later tried to update them, we had to call Attach for each order, which was cumbersome. We refactored to use a short-lived context per batch and full tracking for the entities we intended to modify.

A third trade-off is that AsNoTracking queries do not participate in the identity map, so if you load the same entity twice in the same context, you get two separate instances. This can cause problems in UI code that assumes reference equality. We solved this by using AsNoTrackingWithIdentityResolution in our API layer where we needed consistent object graphs.

Implementation Path: From Tracking-Heavy to Tracking-Light

Moving to a tracking-light strategy does not have to be a big bang rewrite. Follow these steps to migrate incrementally.

Step 1: Identify Read-Heavy Queries

Use profiling tools (like EF Core's logging or an Application Performance Management tool) to find queries that return many rows or are called frequently. Focus on those that do not lead to SaveChanges. These are prime candidates for AsNoTracking.

Step 2: Apply AsNoTracking and Measure

Add .AsNoTracking() to the candidate queries. Measure memory usage (via GC.GetTotalMemory or a memory profiler) and query duration. You should see a significant drop in both. If you see no improvement, the query may be database-bound rather than tracking-bound.

Step 3: Handle Navigation Properties

For any untracked query that accesses navigation properties after materialization, either add .Include() statements or change the code to not rely on lazy loading. This is the most error-prone step; we recommend adding a unit test that verifies navigation properties are loaded correctly.

Step 4: Evaluate Identity Resolution

If your application logic depends on reference equality, switch to AsNoTrackingWithIdentityResolution. Test that duplicate rows are handled correctly. If you use AutoMapper or similar tools, ensure they do not expect tracked entities.

Step 5: Shorten Context Lifetimes

Even with AsNoTracking, long-lived contexts can accumulate other state (like log entries or change tracker entries from explicit Attach calls). Consider using a new DbContext per unit of work—typically per HTTP request or per background job batch.

Risks of Skipping Change Tracking

Disabling tracking is not without risks. The most obvious is that you lose automatic change detection. If you later modify an untracked entity and call SaveChanges, EF will not generate updates for it. You must manually attach the entity and set its state to Modified. Forgetting this step leads to silent data loss—the entity appears unchanged to EF, and no update is sent.

Another risk is concurrency conflicts. When using AsNoTracking, you lose the original snapshot values that EF uses for optimistic concurrency checks. If you need to detect concurrent updates, you must manually pass the original concurrency token (e.g., a row version) when attaching the entity.

There is also a subtle risk with identity resolution in disconnected scenarios. If you load an entity with AsNoTracking, send it to a client, and later receive it back to update, the updated entity may have different property values than when it was loaded. Without tracking, you have no automatic way to know which properties changed. You must either reload the entity from the database and apply changes, or use a mechanism like a change tracker on the client side.

Finally, be aware that AsNoTracking does not mean

Share this article:

Comments (0)

No comments yet. Be the first to comment!