Entity Framework Core performance is one of those topics where most developers either ignore it completely or go straight to rewriting everything in raw SQL. Neither is the right answer. After years of running .NET SaaS products on small budgets, I have found that about five habits fix nearly every slow EF Core app I come across.
This is the short list I work through before I even think about changing the architecture.
Measure before you touch anything
Guessing is the most expensive way to tune a database. Turn on query logging in development and look at the actual SQL being generated. You will often find that one innocent looking page fires two hundred queries.
In production, use whatever application monitoring you already have to find the slowest endpoints. I wrote about the basics in my post on monitoring and alerting for small SaaS teams. Fix the top three slow endpoints and ignore the rest until they matter.
Fix the N+1 query problem first
The N+1 problem is the classic. You load a list of orders, then loop through them and touch each customer, and EF Core quietly runs one extra query per row. With ten rows nobody notices. With ten thousand your database is on its knees.
Use Include or a projection
If you need the related data, load it up front with Include. Better still, project into the shape you actually need with Select, so you only pull the columns the page displays.
I prefer projections for anything that feeds a list or an API response. You skip the change tracking, you skip the columns you never use, and the SQL is usually far smaller. It is also much harder to accidentally trigger lazy loading.
Be careful with lazy loading
Lazy loading looks convenient and then bites you in production. It hides the cost of every property access, so you cannot see the queries in the code. On most of my projects I leave it switched off and load data explicitly.
Use AsNoTracking for read only queries
By default EF Core tracks every entity it loads so it can detect changes. That costs memory and CPU, and for a read only list or report you get nothing back for it.
Add AsNoTracking to queries where you are not going to save changes. On large result sets I have seen this cut response times noticeably, and it is a one word change. If most of your queries are read only, you can set no tracking as the default for the context and opt in when you need to save.
Paging, filtering and indexes
Never return an unbounded list. Always page, and always make sure the ordering column is indexed. A page that loads "all customers" works fine for the first six months and then falls over the week you land a big client.
- Filter in the database, not in memory. Watch for ToList followed by Where, which pulls the whole table first.
- Add indexes for the columns you filter and sort on most, such as tenant id, status and created date.
- Check the query plan for anything that scans a whole table when it should seek.
- Keep an eye on indexes you no longer use, as they slow down every write.
If you run a multi tenant product, tenant id should be at the front of most of your indexes. I went into the wider design in my piece on multi tenant SaaS architecture lessons, and it is a mistake I have personally made.
Batching writes and avoiding chatty code
EF Core batches the changes you make before a single SaveChanges call, so do not call SaveChanges inside a loop. Build up your changes and save once. I have seen import jobs go from forty minutes to under two simply by moving that call outside the loop.
For genuinely large operations, such as updating a million rows, use the bulk methods like ExecuteUpdate and ExecuteDelete. They run a single statement in the database rather than loading every entity into memory first. Long running work like this belongs in a proper background job, not a web request.
Know when raw SQL is the right call
EF Core is brilliant for ninety percent of what a SaaS app does. For the remaining ten percent, such as complex reporting queries with window functions, dropping to raw SQL or a stored procedure is perfectly sensible. There is no prize for forcing everything through LINQ.
The key is to keep it contained. Wrap the raw query in a small, well named method, add a test, and carry on. Do not let it spread through the codebase because one query was awkward.
A quick EF Core performance checklist
Before you blame the database server or throw money at a bigger Azure tier, run through this list.
- Have you logged the real SQL and counted the queries per request?
- Are you using projections or Include to avoid N+1 queries?
- Is AsNoTracking on for read only queries?
- Is every list paged, filtered in the database and backed by an index?
- Is SaveChanges called once per unit of work rather than in a loop?
Nine times out of ten the fix is somewhere on that list, and it costs you an afternoon rather than a bigger hosting bill. My guide to Azure cost optimisation covers what to do when you have fixed the code and still need to trim the invoice. If you want a second opinion on a slow .NET application, have a look at my software development services and get in touch.


