Why Entity Framework Feels Slow – and 5 Fixes That Actually Work

by

in

Entity Framework is a tremendous productivity tool, right up until the point where it quietly issues a few hundred database queries to render a single page. Most “EF is slow” complaints are not the fault of the ORM — they are predictable patterns that are easy to spot once you know what to look for. Here are the fixes that consistently deliver the biggest wins on the .NET systems we work on.

1. Kill the N+1 query

The single most common performance killer. You load a list of orders, then loop over them accessing order.Customer — and each access lazily fires its own query. One hundred orders becomes one hundred and one round trips to the database.

// N+1: one query for orders, then one per order for the customer
var orders = await db.Orders.Where(o => o.Status == "Open").ToListAsync();
foreach (var o in orders)
    Console.WriteLine(o.Customer.Name);   // lazy load fires here

// Fixed: a single query with a join
var orders = await db.Orders
    .Where(o => o.Status == "Open")
    .Include(o => o.Customer)
    .ToListAsync();

Eager-loading with Include collapses those hundred-plus round trips into one. The difference on a busy page is dramatic.

2. Use AsNoTracking for read-only queries

By default EF tracks every entity it returns so it can detect changes on SaveChanges. For a read-only query — a report, a list, an API GET — that tracking is pure overhead in both CPU and memory. Turning it off is a one-line change with a measurable payoff on large result sets:

var rows = await db.Orders
    .AsNoTracking()
    .Where(o => o.OrderDate >= from)
    .ToListAsync();

3. Project to only the columns you need

Materialising full entities when you only display three fields drags every column — including large text blobs — across the wire and into memory. Project into a slim DTO and let the database return only what you use:

var summary = await db.Orders
    .AsNoTracking()
    .Where(o => o.Status == "Open")
    .Select(o => new OrderSummary {
        Id = o.Id, Customer = o.Customer.Name, Total = o.TotalAmount })
    .ToListAsync();

The generated SQL now selects three columns instead of the entire row shape, and EF has far less to materialise.

4. Batch your writes

Calling SaveChanges inside a loop produces a round trip per row. Make all your changes first and save once, so EF can batch the statements. On modern EF Core, a set of inserts becomes a handful of batched commands instead of hundreds of individual ones.

5. Watch the actual SQL

You cannot fix what you cannot see. We always turn on command logging during optimisation so the real queries EF emits are visible — the surprises hide there:

optionsBuilder.LogTo(Console.WriteLine, LogLevel.Information)
              .EnableSensitiveDataLogging();   // dev only

What changes when you apply these

  • Pages that were firing one query per row collapse to a single joined query — the N+1 pattern eliminated outright.
  • Read endpoints get lighter and faster: no change tracking to maintain, and only the columns you actually display crossing the wire.
  • Every one of these is an application-layer fix — no schema changes required.

Takeaway

EF Core is fast when you let it be. Eager-load deliberately, stop tracking what you only read, project to what you display, batch your writes, and keep the generated SQL in view. These five habits eliminate the overwhelming majority of ORM performance problems we are called in to solve. If a .NET application has gotten sluggish as it grew, the fix is usually closer than a rewrite — talk to us.