A read-only endpoint that returns fifty thousand rows is slow and memory-heavy in EF Core. What is the context doing?
The context snapshots every entity it loads so it can work out what changed at save time, which is useful for a handful of entities and pure overhead for a read. AsNoTracking removes the snapshot, and the same mechanism explains why a stray mutation on a tracked entity gets written even though you never called Update.
What the interviewer is scoring
- Whether the candidate explains tracking as a snapshot taken per entity
- That AsNoTracking is proposed with a reason rather than as a remembered flag
- Does the candidate know an accidental mutation on a tracked entity is persisted
- Whether the identity map and its effect on repeated queries is understood
- That the scope of a context is connected to how much it accumulates
Answer
What tracking actually costs
When EF Core materialises an entity from a query, it does not just hand you the
object. It records that entity in the context's change tracker and keeps a
snapshot of its original property values, so that at SaveChanges it can
compare current values against the snapshot and generate UPDATE statements for
whatever differs.
That is the feature. You mutate objects, call save, and the right SQL appears without you describing the change.
For a read it is pure cost, and it is threefold. Every entity is duplicated in
memory by its snapshot. Every entity is inserted into the tracker's internal
dictionaries. And SaveChanges, if it is ever called on that context, walks
every tracked entity comparing every property.
At ten entities none of this is measurable. At fifty thousand it is roughly double the memory and a detection pass over half a million property comparisons.
// Tracked: snapshot per row, all of it discarded when the response is written.
var rows = await _db.Orders
.Where(o => o.CreatedAt >= from)
.ToListAsync(ct);
// Untracked: entities are materialised and forgotten.
var rows = await _db.Orders
.AsNoTracking()
.Where(o => o.CreatedAt >= from)
.ToListAsync(ct);
The rule that follows is simple enough to apply without thinking: if you are not going to save it, do not track it. Some teams set no-tracking as the default for the context and opt in per query, which inverts the failure from "slow reads everywhere" to "a save that silently does nothing", and that second failure is louder and therefore better.
Projecting to a DTO with Select sidesteps the question entirely — anonymous
types and non-entity types are not tracked, and you fetch only the columns you
need rather than every column so you can discard most of them.
The same mechanism, the other symptom
The interesting half of this question is the follow-up, because it is the same behaviour producing a bug rather than a slowdown.
var order = await _db.Orders.FirstAsync(o => o.Id == id, ct);
// A defensive normalisation, or a debug line someone left in.
order.Status = order.Status.Trim();
// Written for something else entirely.
await _db.SaveChangesAsync(ct);
// UPDATE Orders SET Status = ... WHERE Id = ...
Nobody called Update. Nobody intended to write. The entity was tracked, a
property changed, and SaveChanges did exactly what it promises — it saves
changes, and it does not know which of them you meant.
This is why the tracked/untracked decision is a correctness question as well as
a performance one. An entity you fetched to inspect, passed to a mapper, and let
a formatting helper touch is an entity you may have modified. AsNoTracking
makes that impossible: there is no snapshot, so there is nothing to detect and
nothing to write.
The identity map, which is why the row is one object
The tracker also serves as an identity map within a context. Query the same row twice and you get the same instance back, not two.
var a = await _db.Orders.FirstAsync(o => o.Id == id, ct);
var b = await _db.Orders.FirstAsync(o => o.Id == id, ct);
ReferenceEquals(a, b); // true, with tracking on
That is often what you want — two parts of a request agreeing about one entity — and it has a consequence people are surprised by. The second query still goes to the database, but the tracker keeps the first instance's values, so a change made by someone else in between is not reflected in the object you hold. The data is fresh in the result set and stale in the entity.
With AsNoTracking, the same two queries produce two independent objects, each
reflecting what the database said at the time.
Context lifetime is part of the same subject
Everything above accumulates for as long as the context lives. Registered scoped, which is the default in ASP.NET Core, the context lasts one request and the tracker is discarded with it — fine.
Two arrangements break that. A context held as a singleton accumulates every entity anyone loads for the life of the process, which is both a leak and a correctness problem, and it is one of the shapes the captive-dependency bug takes: inject a scoped context into a singleton and the singleton holds the first request's instance forever.
And a long-running job that loops over batches inside one context grows without bound even with tracking notionally justified. The conventional handling is a fresh context per batch, obtained from a context factory rather than injected.
What to check when a read is slow
Confirm the query is untracked or projected. Confirm you are selecting the columns you need rather than whole entities. Then look at what the query actually sent, because tracking overhead and an N+1 from a lazy-loaded navigation feel identical from the outside and only one of them is fixed by a flag:
optionsBuilder.LogTo(Console.WriteLine, LogLevel.Information);
Reading the generated SQL routinely is the habit that separates people who are effective with an ORM from people at its mercy, and it answers most performance questions in this area faster than any amount of reasoning about the abstraction.
The context is not a query API with a cache attached, it is a unit of work that is watching everything you loaded. Tell it not to watch what you are only reading.
Likely follow-ups
- You never called Update and the row changed anyway. How?
- What does SaveChanges do when nothing was modified?
- Two queries return the same row. Do you get one object or two?
- When is tracking worth keeping on a large result set?
Related questions
- This EF Core query is slow. How do you work out why, and what does change tracking have to do with it?mediumAlso on ef-core and change-tracking5 min
- Your Node service has low CPU but latency spikes for every request at the same moment. What is happening?mediumAlso on performance4 min
- Walk me through how you read a PostgreSQL execution plan.mediumAlso on performance5 min
- A query has an index on the filtered column but the plan shows a sequential scan. Walk me through how you would diagnose it.hardAlso on performance3 min
- How does the .NET garbage collector decide what to collect, and how would you make a hot path allocate less?hardAlso on performance6 min
- Walk me through what happens between the source you write and the instructions the CPU runs, and where does a JIT fit into that?mediumAlso on performance5 min
- Walk me through the phases of the Node event loop.mediumAlso on performance4 min
- A hot path allocates heavily and garbage collection is showing up in your profile. What does Span give you that a substring does not?hardAlso on performance4 min