Tracking vs. no-tracking, and why your read endpoints are slow
“Why is this endpoint so slow? It only reads three tables.”
Reader — it does not read three tables. It reads three tables, and then it tracks every row it loaded, and then your using block exits, and then it does change-tracking on rows nobody changed, and then it walks the proxies, and then it returns.
EF Core's default behavior is change tracking. The DbContext keeps a copy of every row it loads, so that when you SaveChanges(), it can compute the delta. This is a great default for write paths — but it's a quiet, expensive default for read paths.
The fix is one line
return await db.Posts
.AsNoTracking()
.Where(p => p.Published)
.OrderByDescending(p => p.CreatedAt)
.Take(30)
.ToListAsync(ct);
AsNoTracking() says: I am only reading. Do not snapshot. Do not track. Do not bother. On hot read paths, we routinely measure 2–5× speedups, and substantially less GC pressure.
When not to use it
- When you intend to mutate the result and call
SaveChangeson the same context. - When you load a graph that uses the identity map to dedupe references — though this is rare in modern code.
A nuance: split queries
If you .Include() two collection navigations, EF will, by default, generate one giant join. The result is a Cartesian explosion: N × M rows where you wanted N + M.
var nb = await db.Notebooks
.AsNoTracking()
.AsSplitQuery()
.Include(n => n.Posts)
.Include(n => n.Followers)
.FirstAsync(n => n.Id == id);
AsSplitQuery() makes EF run three small queries instead of one enormous one. On a notebook with 200 posts and 5000 followers, the difference is the difference between fast and catastrophic.
Summary
| Pattern | Use when |
|---|---|
AsNoTracking() |
Reading only. Almost always, in API endpoints. |
| Tracking (default) | Reading and then mutating in the same DbContext. |
AsSplitQuery() |
Multiple .Include() on collection navigations. |
Make AsNoTracking() your default for read endpoints. Your p99 will thank you.