Skip to content

Case 4: The Lazy Loading Trap (The Hidden N+1 Problem) ​

The Scenario πŸ“ ​

  • System: An ASP.NET Core application with EF Core. Lazy Loading has been enabled for convenience (using the Microsoft.EntityFrameworkCore.Proxies package).
  • Problem: We need to display a list of 10 blog posts, and under each post, we need to show a list of its related Tags.

The Problematic Code (It Looks Innocent) ​

The code in a Razor Page or Controller looks clean and simple.

csharp
// 1. Get 10 posts
var posts = await _context.Posts.Take(10).ToListAsync();

// 2. Loop and display (this is where the problem happens)
foreach (var post in posts)
{
    Console.WriteLine($"Post: {post.Title}");

    // When post.Tags is accessed for the first time, Lazy Loading is triggered,
    // running a hidden query to get the tags for THIS ONE POST.
    foreach (var tag in post.Tags)
    {
        Console.WriteLine($"  - Tag: {tag.Name}");
    }
}

The Hidden Bottleneck 🧐 ​

  1. What is Lazy Loading?: When this feature is on, EF Core creates special "proxy" versions of your objects. When you access a related property like post.Tags for the first time and it hasn't been loaded yet, the proxy automatically sends a query to the database to get that data.

  2. The N+1 Problem:

    • Query 1: _context.Posts.Take(10).ToListAsync() - This sends 1 query to get 10 posts.
    • Queries 2 to 11 (The "N" queries): Inside the foreach loop, when the code hits foreach (var tag in post.Tags), EF Core secretly runs a new query for each post: SELECT * FROM "Tags" WHERE "PostId" = @p0.
    • Total: The system makes 11 separate trips to the database just to display this simple page.

The Solution: Load Data Explicitly (Eager Loading) βœ… ​

  • The Logic: Instead of letting EF Core be "lazy" and fetch data on demand, we tell it to load all the data we need in the very first query.

  • The Optimized Code:

    csharp
    var posts = await _context.Posts
                              .Include(p => p.Tags) // Use Include to Eager Load
                              .AsNoTracking() // Still use AsNoTracking for read-only queries
                              .Take(10)
                              .ToListAsync();
    
    // This loop is now completely safe; no hidden queries are run.
    foreach (var post in posts)
    {
        Console.WriteLine($"Post: {post.Title}");
        foreach (var tag in post.Tags) // The data is already in memory
        {
            Console.WriteLine($"  - Tag: {tag.Name}");
        }
    }

The Results ✨ ​

  1. A Single, Efficient Query: EF Core now generates one single, efficient query that uses a LEFT JOIN to get both the posts and their related tags in one go.
    sql
    SELECT "p"."Id", "p"."Title", ..., "t"."Id", "t"."Name", ...
    FROM "Posts" AS "p"
    LEFT JOIN "Tags" AS "t" ON "p"."Id" = "t"."PostId"
    ...
  2. Fewer Database Round-trips: The number of trips to the database is reduced from 11 to just 1. This is a massive performance improvement, especially when network latency is high.

Conclusion:

  • Lazy Loading is convenient for rapid development or simple scenarios, but it's a double-edged sword and a common cause of hard-to-detect N+1 problems.
  • Golden Rule: Always eager load the data you know you will need by using .Include(). Be in control of what your queries are doing.
  • Many experienced teams even disable Lazy Loading entirely to prevent these accidental performance issues.