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:

  1. One query to fetch the 50 orders.
  2. 50 more queries to get the Customer for each of those 50 orders.
  3. 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 JOINs.

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

FAQ

Does EF Core use lazy loading by default?

No. EF Core disables lazy loading by default. It must be explicitly enabled via proxies or configuration.

Why is lazy loading slow in ASP.NET Core APIs?

Lazy loading can cause multiple hidden SQL queries (N+1 problem), increasing database round-trips and making API responses slower.

How do I disable lazy loading in EF Core?

Avoid enabling lazy loading proxies, or explicitly set UseLazyLoadingProxies(false) in DbContext configuration.

When is lazy loading acceptable?

In admin tools, prototypes, or low-traffic apps where performance is not critical and convenience outweighs cost.

What should I use instead of lazy loading?

Use eager loading with Include/ThenInclude, projection queries with Select, or explicit loading when necessary.

About the Author

@CodeCrafter is a software engineer who builds real-world systems , from resilient ASP.NET Core backends to clean, maintainable Angular frontend. With 11+ years in production development, he shares what actually works when shipping software that has to last.