The N+1 Problem
This is the most common EF Core performance killer. You load a list of orders, then for each order EF issues another query to load its items โ 1 query becomes N+1.
// BAD โ N+1 queries
var orders = await _db.Orders.ToListAsync();
foreach (var order in orders)
{
// triggers a new query per order!
Console.WriteLine(order.Items.Count);
}
// GOOD โ single join query
var orders = await _db.Orders
.Include(o => o.Items)
.ToListAsync();
AsNoTracking for Read Queries
The EF change tracker adds significant overhead for queries that will never be updated. On read-heavy endpoints, AsNoTracking() delivers a 20โ40% speed boost.
// API endpoint returning warehouse inventory
var items = await _db.WarehouseItems
.AsNoTracking()
.Where(i => i.LocationCode == locationCode)
.Select(i => new ItemDto(i.Id, i.Sku, i.Quantity))
.ToListAsync();
Compiled Queries
For hot paths that run thousands of times per minute, EF Core needs to translate LINQ to SQL each call. Compiled queries cache the translation:
// Compiled once, reused on every call
private static readonly Func<AppDbContext, string, Task<WarehouseItem?>>
GetItemBySku = EF.CompileAsyncQuery(
(AppDbContext db, string sku) =>
db.WarehouseItems.FirstOrDefault(i => i.Sku == sku));
// Usage โ no LINQ translation overhead
var item = await GetItemBySku(_db, "SKU-12345");
Pagination โ Never Skip Without a Keyset
OFFSET/FETCH pagination gets slower as pages increase. With 1M rows, page 50,000 is painful. Switch to keyset pagination:
// SLOW for large pages
var page = await _db.Orders
.OrderBy(o => o.CreatedAt)
.Skip(pageIndex * pageSize)
.Take(pageSize)
.ToListAsync();
// FAST โ keyset cursor
var page = await _db.Orders
.Where(o => o.CreatedAt > lastSeenDate)
.OrderBy(o => o.CreatedAt)
.Take(pageSize)
.ToListAsync();
Profiling with EF Core Logging
Always profile in staging before shipping. Enable SQL logging to see exactly what queries EF generates:
// appsettings.Development.json
{
"Logging": {
"LogLevel": {
"Microsoft.EntityFrameworkCore.Database.Command": "Information"
}
}
}
Better yet, use MiniProfiler or Azure Application Insights to capture slow queries in production. A query taking >100ms on a hot path is a bug, not a warning.