We’ve all been there. Your new API endpoint flies on your machine, returning customer orders in 50ms. You deploy it, and suddenly… it’s taking two seconds. The logs don’t show any obvious errors, but your app is crawling. The culprit is often a feature designed for convenience: lazy loading.
Lazy loading in Entity Framework Core feels like magic during development, but it can hide a massive performance tax that will absolutely cripple your application at scale.
So What Exactly is Lazy Loading?
Lazy loading means EF Core automatically grabs related data from the database only when you access it. Instead of you explicitly telling EF what you need upfront, EF Core reacts to your code. When you touch a navigation property for the first time, boom, it fires off a new database query to get that data.
Sounds handy, right? The problem is that each of these property accesses can trigger a separate, hidden database round-trip. What looks like a simple line of C# can become a silent storm of SQL queries.
The Sneaky Performance Killer: The N+1 Query Problem
The most common side effect of lazy loading is the infamous N+1 query problem. It starts with one simple query and then spirals.
Take a look at this standard ASP.NET Core controller action that fetches 50 orders:
public async Task<IActionResult> GetOrderSummary()
{
var orders = await _context.Orders.Take(50).ToListAsync();
// The problem kicks in right here, usually during serialization
var result = orders.Select(o => new
{
o.Id,
CustomerName = o.Customer.Name, // Fires one query per order
ProductCount = o.OrderItems.Count // Fires another query per order
});
return Ok(result);
}
You’d think this would be one, maybe two, database hits. Nope. Here’s what EF Core actually does:
- One query to fetch the 50 orders.
- 50 more queries to get the
Customer
for each of those 50 orders. - Another 50 queries to get the
OrderItems
for each of those 50 orders.
That’s a grand total of 101 database queries instead of one. With even minimal network latency, that’s how a 50ms local response turns into a multi-second disaster in production.
This one burned me in production once. We had an API where response times crept up from 100ms to over 5 seconds as more data was added. The root cause? A single endpoint returning a list of entities. The JSON serializer was innocently walking the object graph, touching lazy-loaded properties, and unleashing hundreds of queries. Turning off lazy loading and using a projection fixed it instantly.
This often bites you during JSON serialization. When you return IEnumerable<Order>
, the serializer has to read every property to build the response. If those properties are lazy-loaded, it triggers the queries right as it’s trying to send the data back to the client. Painful.
How to Fix It: Explicit Loading Is Your Friend
The secret to fast, predictable data access is being explicit. You need to tell EF Core exactly what you need, every time.
1. Eager Loading with Include
This is your go-to for loading full entity graphs. Use the Include
method to tell EF Core which related data to fetch in the very first query. EF Core will generate a single, larger query with the right JOIN
s.
public async Task<List<Order>> GetOrdersWithDetails()
{
return await _context.Orders
.Include(o => o.Customer)
.Include(o => o.OrderItems)
.Take(50)
.ToListAsync();
}
This pulls all the data you need in one round-trip and completely avoids the N+1 problem.
2. Projections with Select
For read-only API endpoints, projections are king. Instead of fetching full, tracked entities from the database, you shape the query to return a DTO or an anonymous type with only the fields you need.
public async Task<IActionResult> GetOrderSummaries()
{
var summaries = await _context.Orders
.Select(o => new OrderSummaryDto
{
Id = o.Id,
OrderDate = o.OrderDate,
CustomerName = o.Customer.Name,
Total = o.OrderItems.Sum(i => i.Price * i.Quantity)
})
.Take(50)
.ToListAsync();
return Ok(summaries);
}
This is the most efficient method by far. EF Core translates this into a highly optimized SQL query that only pulls the columns you specify. It reduces network traffic, lowers memory usage, and skips change tracking overhead.
Pro Tip: Honestly, projections should be your default for any data that is read and sent out of your API. It’s the single best thing you can do for your data layer performance.
3. Split Queries for Complex Relationships
Sometimes, eager loading multiple collections (like OrderItems
and OrderNotes
on the same Order
) can cause a “cartesian explosion,” leading to tons of duplicated data being sent over the wire. To fix this, EF Core has AsSplitQuery
. It’s smart enough to fetch the related collections in separate, smaller queries behind the scenes, giving you the best of both worlds.
var orders = await _context.Orders
.AsSplitQuery()
.Include(o => o.OrderItems)
.Include(o => o.Shipments)
.Where(o => o.CustomerId == customerId)
.ToListAsync();
My Advice: Just Turn It Off
For 99% of APIs, I strongly recommend disabling lazy loading completely. It forces you to be deliberate about your data access patterns, which is a good thing.
You can do this when you configure your DbContext
in Program.cs
. If you’re using the Microsoft.EntityFrameworkCore.Proxies
package, you can explicitly disable it.
// In Program.cs
builder.Services.AddDbContext<AppDbContext>(options =>
options.UseSqlServer(connectionString)
.UseLazyLoadingProxies(false)); // Add this if you use proxies
If you never installed the proxies package in the first place, lazy loading is already off by default. Keep it that way.
The Final Take: When to Use It (and When Not To)
The fastest database query is the one you write intentionally. Lazy loading is a bad trade-off in production systems: it gives you a little convenience in exchange for unpredictable, often terrible, performance.
- Here’s when I’d avoid it: For any public-facing or high-throughput API. The risk of a hidden performance nightmare is just too high. Stick with projections (
Select
) for reads and explicit loading (Include
) for updates. - Here’s when I might use it: Maybe in a low-traffic internal admin panel or a quick-and-dirty console app where performance isn’t the primary concern and you just want to get things done. Even then, I’d be careful.
For building robust and fast ASP.NET Core applications, always favor being explicit. Your future self will thank you when you’re not debugging a performance fire at 2 AM.
References
- Loading Related Data - EF Core (Microsoft Docs)
- N+1 problem - Wikipedia
- Stop the silent killer of your database: N+1 queries
FAQ
Does EF Core use lazy loading by default?
Why is lazy loading slow in ASP.NET Core APIs?
How do I disable lazy loading in EF Core?
UseLazyLoadingProxies(false)
in DbContext
configuration.When is lazy loading acceptable?
What should I use instead of lazy loading?
Include
/ThenInclude
, projection queries with Select
, or explicit loading when necessary.