Skip to content
QSWEQB
mediumConceptScenarioMidSenior

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.

4 min readUpdated 2026-07-26

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

Further reading

ef-corechange-trackingperformanceormdotnet