Entity Framework (EF) is a staple in .NET development, but it's surprisingly easy to write data access code that works fine in testing and collapses under real-world load. The same query that returns 50 rows locally might generate thousands of SQL statements in production. This isn't a flaw in EF—it's a mismatch between how we think about data and how EF translates our LINQ into SQL. In this guide, we'll walk through five specific mistakes that consistently slow down .NET apps. Each section explains the symptom, shows the problematic pattern, and offers a concrete fix. We assume you're using EF Core 6 or later, but most advice applies to earlier versions too.
1. The N+1 Query Problem: When Lazy Loading Betrays You
What is N+1 and why does it hurt?
N+1 is the most common performance killer in EF applications. It happens when you load a parent entity and then access its navigation property, causing EF to execute one query for the parent and one additional query for each child row. If you have 100 orders, you'll run 1 query to get the orders and then 100 more to get the line items—101 total trips to the database. The worst part: this often goes unnoticed during development because the local database is fast and the test data is small. In production, with thousands of users, the database server gets overwhelmed by the sheer number of round trips.
How to detect N+1
Enable EF Core logging to see all SQL queries. Look for repeated, identical SELECT statements that differ only in the WHERE clause parameter. For example, you might see one SELECT for Orders followed by many SELECTs for OrderLines WHERE OrderId = @p1, @p2, etc. SQL Server Profiler or the Azure SQL Query Performance Insight tool can also highlight high-volume queries. Another tell: response times that grow linearly with the number of parent records.
The fix: eager loading with Include
Replace lazy loading with eager loading using the .Include() method. For example, context.Orders.Include(o => o.OrderLines).ToList() generates a single query with a JOIN. If you need multiple related entities, chain Include calls or use ThenInclude(). Be mindful of cartesian explosion—joining too many tables can return huge result sets. In those cases, consider splitting the query into two round trips with Load() or using a projection with Select() to fetch only the needed columns.
2. Ignoring Database Indexes: The Silent Serial Killer
Why EF doesn't create indexes automatically
EF Core's migrations create indexes only for primary keys and foreign keys by default. Any column you filter on frequently—like WHERE Status = 'Active' or WHERE CreatedAt > @date—remains unindexed unless you explicitly add an index. Without an index, SQL Server performs a full table scan. For tables with millions of rows, that single query can take seconds. Multiply that by concurrent users, and your app becomes unresponsive.
How to identify missing indexes
Check the execution plan of slow queries. SQL Server Management Studio can show you missing index suggestions (green text in the plan). Alternatively, query sys.dm_db_missing_index_details. In EF Core, enable EnableSensitiveDataLogging() and EnableDetailedErrors() to capture query text. Add indexes in migrations using the Fluent API: entity.HasIndex(e => e.Status). For composite filters, create composite indexes. Remember that indexes speed up reads but slow down writes—balance based on your workload.
Common indexing mistakes
One team I read about indexed every column individually, which bloated the database and made inserts slow. Another team forgot to index foreign key columns used in joins, causing nested loop joins with millions of iterations. The fix: profile your top 10 slowest queries and index the columns used in WHERE, JOIN, and ORDER BY clauses. Use covering indexes when the query only needs a few columns.
3. Retrieving More Data Than Needed: The Select Sin
The all-columns trap
Calling .ToList() on an IQueryable returns all columns of the entity. If your table has 50 columns but the UI only needs 3, you're transferring 47 columns of unnecessary data over the network. This increases memory usage on the app server, bandwidth consumption, and time to serialize the response. In a high-traffic API, this can triple response times.
Projection to the rescue
Use .Select() to project only the columns you need. For example: context.Orders.Where(o => o.Date > cutoff).Select(o => new { o.Id, o.Total, o.CustomerName }).ToList(). This generates a SELECT with only those columns. If you're using AutoMapper, use ProjectTo to map directly to a DTO. Avoid selecting entire entities and then mapping in memory—that defeats the purpose because EF still fetches all columns.
When not to project
If you need to update the entity later, you must retrieve the full entity (or at least the concurrency token). For read-only scenarios, always project. Also be careful with navigation properties inside projections—each .Include in a projection can still cause extra joins. Test the generated SQL to ensure it's a single query.
4. Misusing Lazy Loading and Explicit Loading
Why lazy loading is dangerous in web apps
Lazy loading works well in desktop or thick-client apps where the DbContext stays alive. In web applications, the DbContext is typically disposed after the request ends. If you try to access a navigation property after disposal, you get a ObjectDisposedException. Even worse, if the context is still alive, lazy loading triggers queries during serialization, often causing N+1. Many teams disable lazy loading entirely in ASP.NET Core and rely on eager loading or explicit loading.
Explicit loading as a middle ground
Sometimes you don't know in advance whether you'll need a navigation property. Explicit loading lets you load it on demand with a single round trip: context.Entry(order).Collection(o => o.OrderLines).Load(). This is better than lazy loading because you control when the query fires. Use it sparingly—if you always need the data, eager loading is cleaner.
When to keep lazy loading
If you're building a background service or a console app where the context lives for a short, predictable time, lazy loading can simplify code. But log all queries in development and watch for unexpected round trips. A good rule: start with lazy loading disabled and enable it only after proving it won't cause performance issues.
5. Not Using Compiled Queries for Hot Paths
The cost of query compilation
Every time EF executes a LINQ query, it must parse the expression tree, build a SQL string, and cache the compiled version. For most queries, this overhead is negligible. But for high-frequency queries—like loading a user's profile on every request—the compilation cost adds up. EF Core caches query plans by default, but the first execution still pays the compilation penalty. For truly hot paths, you can precompile the query.
How to use compiled queries
Define a static delegate using EF.CompileQuery or EF.CompileAsyncQuery. For example: private static readonly Func. Then call it as GetUserById(context, userId). This eliminates the compilation step entirely. The downside: you lose the ability to compose additional filters on the query. Use compiled queries only for the most critical, fixed-shape queries.
When compiled queries backfire
If your query shape varies (different filters, ordering, includes), a compiled query won't work. You'd need multiple compiled queries for each variation, which becomes hard to maintain. In those cases, rely on EF's built-in caching and focus on other optimizations first. Measure before and after—the improvement is often modest unless the query runs hundreds of times per second.
6. Tools, Setup, and Environment Realities
Essential tools for diagnosing EF performance
You can't fix what you can't see. Start with EF Core's built-in logging: configure optionsBuilder.LogTo(Console.WriteLine, LogLevel.Information) to see SQL queries and execution times. For deeper analysis, use SQL Server Profiler (or the free Extended Events) to capture query statistics. The MiniProfiler library integrates with ASP.NET Core and shows per-query timings in the browser. For production, consider Application Insights or Azure Monitor to track slow dependencies.
Setting up a realistic test environment
Many performance bugs only appear under load. Use a staging database with production-like data volume—at least 100,000 rows per table. Simulate concurrent users with tools like k6 or JMeter. Monitor the number of database connections and query throughput. A single N+1 query that returns 50 rows might not show up in a single-user test but will saturate connections under 100 concurrent users.
Common environment pitfalls
Developers often test against a local SQL Server Express instance with small data, then wonder why the app slows down in production. Always test with realistic data. Also, be aware of network latency—a chatty EF query pattern that works fine on a local network can become a bottleneck when the app and database are in different data centers. Use Azure SQL's built-in query store to capture historical performance data.
7. Pitfalls, Debugging, and What to Check When It Fails
The query plan rabbit hole
When a query is slow, the first step is to examine the actual execution plan. Look for table scans, key lookups, and spills to tempdb. Sometimes the plan looks fine for small data but changes under load due to parameter sniffing. EF Core uses parameterized queries by default, which helps plan reuse but can cause sniffing issues. If you see different plans for the same query, consider using OPTION (RECOMPILE) or OPTION (OPTIMIZE FOR UNKNOWN) via raw SQL.
Transaction and connection management
Long-running transactions can block other queries. EF Core by default uses implicit transactions for SaveChanges. If you wrap multiple saves in a single explicit transaction, keep it short. Also, avoid holding a transaction while iterating over results—that can lock rows for extended periods. Use AsNoTracking for read-only queries to reduce overhead.
Common false fixes
Some developers try to fix performance by adding .AsEnumerable() early, which forces client-side evaluation. This moves the work to the app server and often makes things worse. Another mistake is overusing .Include() for every navigation property, leading to massive JOINs. The correct approach: start with the simplest query that returns the data you need, measure, and add optimizations one at a time.
8. What to Do Next: A Concrete Action Plan
Audit your existing codebase
Start by identifying the top 10 slowest endpoints using your APM tool. For each, examine the EF queries being generated. Look for N+1 patterns, missing indexes, and unnecessary columns. Create a backlog of performance issues and prioritize by impact. Use the following checklist:
- Enable EF logging and capture all queries for a typical request.
- Count the number of round trips per request (aim for fewer than 5).
- Check for repeated queries with different parameters (N+1).
- Review execution plans for missing index recommendations.
- Ensure read-only queries use
AsNoTracking. - Verify that projections are used where possible.
Adopt performance-first habits
For new development, follow these rules:
- Always start with eager loading unless you have a specific reason not to.
- Write queries as projections from the start—don't retrieve full entities for read-only use.
- Add indexes as part of the migration for any column used in a WHERE clause.
- Use compiled queries for hot paths after measuring the need.
- Set up performance regression tests that run against a realistic database.
Monitor continuously
Performance is not a one-time fix. Set up alerts for slow queries in production. Review query store data weekly. When you deploy a new migration, check that it doesn't introduce missing indexes. Educate your team about these five mistakes—a shared understanding prevents regressions. With these practices, your EF-based app will stay fast as it grows.
Comments (0)
Please sign in to post a comment.
Don't have an account? Create one
No comments yet. Be the first to comment!