How I Optimized an API Endpoint to Make It 15x Faster
A simple, step-by-step story of how I made a slow ASP.NET Core API endpoint 15x faster using EF Core projection, AsNoTracking, paging, and indexes.
How I Optimized an API Endpoint to Make It 15x Faster
One morning, a teammate sent me a message: "Our orders page takes 6 seconds to load. People are getting angry." That endpoint had worked fine when we had a few hundred orders. Now we had a few hundred thousand. The code never changed, but the data grew. And the endpoint slowed to a crawl.
This is the story of how I took that endpoint from about 3 seconds down to about 200 milliseconds — roughly 15 times faster — without rewriting the whole app. I will walk you through every step in plain English. You only need to know a little C# and a little about databases to follow along.
A simple everyday analogy
Imagine your mother sends you to the market with a shopping list of 50 items. There are two ways to do this.
The slow way: You walk to the market, buy one item, walk all the way home, drop it off, then walk back for the next item. Fifty trips for fifty items. You would be exhausted and the day would be gone.
The fast way: You take the list, walk once, fill one big bag, and come home. One trip.
A slow API endpoint is almost always doing the "one trip per item" thing without knowing it. The database is the market. Each trip is a query. Our job today is to turn 50 trips into 1 trip, and to carry only what we actually need.
The endpoint I started with
Here is a cleaned-up version of the original endpoint. It returns a list of recent orders with the customer name and the items in each order. Read it slowly and see if you can spot the trouble.
app.MapGet("/api/orders", async (AppDbContext db) =>
{
// Load every order as a full entity, with change tracking ON
var orders = await db.Orders
.OrderByDescending(o => o.CreatedAt)
.ToListAsync();
var result = new List<OrderDto>();
foreach (var order in orders)
{
// This line quietly runs a NEW query for each order!
var customer = await db.Customers.FindAsync(order.CustomerId);
result.Add(new OrderDto
{
Id = order.Id,
CustomerName = customer!.Name,
Total = order.Lines.Sum(l => l.Price * l.Quantity),
ItemCount = order.Lines.Count
});
}
return Results.Ok(result);
});This code looks innocent. It even reads nicely. But it hides three separate performance problems that stack on top of each other. Let me show you each one and how I fixed it.
Step 1: Measure before you touch anything
The first rule of making things faster: measure first, guess never. If you change code based on a hunch, you might "fix" something that was never slow and miss the real problem.
I turned on EF Core's SQL logging so I could see every query the endpoint sent to the database. In .NET 10 this is a one-line change:
builder.Services.AddDbContext<AppDbContext>(options =>
options
.UseSqlServer(connectionString)
.LogTo(Console.WriteLine, LogLevel.Information)); // shows the real SQLWhen I hit the endpoint, my console exploded. For a page of 50 orders, I counted 52 separate SQL queries: one for the orders, one for every customer lookup, plus extra ones loading the lines. That was my proof. The market trips were real.
My measuring loop
Steps
Reproduce
Hit the slow endpoint locally
Measure
Turn on SQL logging and a timer
Find worst spot
Count queries and time
Fix one thing
Change a single cause
Measure again
Prove it actually helped
Step 2: Kill the N+1 query problem
This is the big one. The name "N+1" means: 1 query to get the list, plus N more queries — one for each row. With 50 orders that is 1 + 50 = 51 trips to the market.
The culprit was this line inside the loop:
var customer = await db.Customers.FindAsync(order.CustomerId);Every time the loop runs, it goes back to the database for one customer. The fix is to ask the database to join the data and bring it all back in one query. The cleanest tool for this is projection — using .Select(...) to shape exactly what we want.
Here is the same data, but pulled in a single query with projection:
var result = await db.Orders
.OrderByDescending(o => o.CreatedAt)
.Select(o => new OrderDto
{
Id = o.Id,
CustomerName = o.Customer.Name, // joined, no extra query
Total = o.Lines.Sum(l => l.Price * l.Quantity), // computed in the database
ItemCount = o.Lines.Count // counted in the database
})
.ToListAsync();Notice what changed. We no longer pull whole Order objects into memory and then go fetch customers. We describe the final shape we want, and EF Core writes one SQL statement that joins orders to customers and adds up the totals on the database server. The 52 queries collapsed into 1.
After this change alone, the endpoint dropped from about 3 seconds to roughly 700 milliseconds. That is already more than 4x faster, from one fix.
Step 3: Stop carrying things you do not need
The new query was better, but I was still asking for more than I needed. The original loaded full Order entities with every column. Projection already helped here, because .Select only reads the columns named in the DTO. This is the "carry one small bag, not the whole shelf" idea.
A quick comparison of what travels across the wire:
| Approach | Columns read | Rows in memory | Round trips |
|---|---|---|---|
| Full entities + loop | All columns | 50 orders + 50 customers | 52 |
Projection (.Select) | Only 4 fields | 50 small DTOs | 1 |
The lesson is simple: the database is fast, but moving data is slow. The less data you ask for, the less there is to read, send, and turn into objects.
Step 4: Turn off change tracking for read-only data
By default, EF Core "tracks" every entity it loads. Tracking means it keeps a second hidden copy of each row so it can later figure out what you changed and save it. That is wonderful when you are editing data. But our endpoint only reads data. We never save anything. So all that tracking work is pure waste.
The fix is one method call: AsNoTracking().
var result = await db.Orders
.AsNoTracking() // skip change tracking
.OrderByDescending(o => o.CreatedAt)
.Select(o => new OrderDto
{
Id = o.Id,
CustomerName = o.Customer.Name,
Total = o.Lines.Sum(l => l.Price * l.Quantity),
ItemCount = o.Lines.Count
})
.ToListAsync();For read-only list endpoints, AsNoTracking() commonly saves 20 to 40 percent of the time and a good chunk of memory. It is one of the highest-value, lowest-risk changes you can make. As a rule of thumb: if the data is going straight out as JSON and never saved, add AsNoTracking().
Step 5: Never return everything — page the results
Even with one fast query, returning all orders is a trap. Today it is 50 rows. Next year it might be 5 million. No human reads 5 million rows on one screen, and no browser enjoys receiving them.
The answer is paging: return a small, fixed-size slice, like 20 rows at a time. EF Core does this with Skip and Take.
app.MapGet("/api/orders", async (AppDbContext db, int page = 1, int pageSize = 20) =>
{
pageSize = Math.Clamp(pageSize, 1, 100); // never let a caller ask for too much
var result = await db.Orders
.AsNoTracking()
.OrderByDescending(o => o.CreatedAt)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.Select(o => new OrderDto
{
Id = o.Id,
CustomerName = o.Customer.Name,
Total = o.Lines.Sum(l => l.Price * l.Quantity),
ItemCount = o.Lines.Count
})
.ToListAsync();
return Results.Ok(result);
});Paging keeps your endpoint fast forever, no matter how big the table grows, because you always read a fixed small slice.
Step 6: Add the right database index
By now the C# side was clean. But the database still had to sort every order by CreatedAt to find the newest ones. With hundreds of thousands of rows, sorting the whole table on every request is slow.
The fix lives in the database, not the code: an index. An index is like the alphabetical thumb-tabs on a dictionary. Instead of flipping through every page, the database jumps straight to the right spot. Since we order by CreatedAt, we index that column.
modelBuilder.Entity<Order>()
.HasIndex(o => o.CreatedAt)
.IsDescending(); // matches our OrderByDescendingA good habit: look at the WHERE, JOIN, and ORDER BY parts of your hottest queries, and make sure those columns are indexed. This single index took the query plan from a full table scan to a quick lookup.
Step 7 (optional): Compiled queries for the hottest paths
There is one more trick, but I want you to treat it carefully. Every time EF Core runs a LINQ query, it spends a tiny bit of time translating that LINQ into SQL. EF Core caches this, so it is usually cheap. But on an endpoint that runs thousands of times per second, even tiny costs add up.
A compiled query does that translation once and reuses it forever:
private static readonly Func<AppDbContext, int, int, IAsyncEnumerable<OrderDto>> GetOrdersPage =
EF.CompileAsyncQuery((AppDbContext db, int skip, int take) =>
db.Orders
.AsNoTracking()
.OrderByDescending(o => o.CreatedAt)
.Skip(skip)
.Take(take)
.Select(o => new OrderDto
{
Id = o.Id,
CustomerName = o.Customer.Name,
Total = o.Lines.Sum(l => l.Price * l.Quantity),
ItemCount = o.Lines.Count
}));Only reach for compiled queries on the hottest 5 percent of your endpoints, and only after measuring. For everything else, the earlier steps already win. (A small note: some popular libraries like MediatR and MassTransit are now under commercial licenses, so if you were thinking of adding a big library just for performance, check the license and remember you usually do not need one for plain query tuning.)
The results, side by side
Here is the full journey. Each row is one change, stacked on top of the last.
| Change made | Approx. time | Queries per request |
|---|---|---|
| Original (N+1 loop) | ~3000 ms | 52 |
| Projection in one query | ~700 ms | 1 |
| Added AsNoTracking | ~520 ms | 1 |
| Added paging (20 rows) | ~260 ms | 1 |
| Added index on CreatedAt | ~200 ms | 1 |
From about 3000 ms to about 200 ms. That is the 15x in the title, and not a single fancy library was needed. Just careful, ordinary fixes.
The full optimization recipe
Steps
Measure
Log SQL and time the request
Kill N+1
One query, not one per row
Project
Select only needed fields
No tracking
AsNoTracking for read-only
Page
Skip and Take a small slice
Index
Index WHERE/JOIN/ORDER BY columns
Compile (rare)
Only the hottest endpoints
Why this order matters
I fixed things in a deliberate order, and that order is part of the lesson. The N+1 fix gave the biggest jump, so it went first. Projection and AsNoTracking were cheap and safe, so they came next. Paging protected the future. The index helped the database. Compiled queries came last because they help the least for the most effort.
If you only remember one sentence from this whole article, let it be this: do the cheap, high-impact fixes first, and prove each one with a measurement.
Common mistakes to avoid
A few traps I have seen students and even seniors fall into:
- Optimizing by guessing. Always measure. The slow part is often not where you think.
- Adding
Includefor everything. Loading many related collections at once can cause a "cartesian explosion" where rows multiply. Projection usually avoids this. - Leaving lazy loading on in production. It silently triggers N+1 queries behind your back.
- Forgetting
AsNoTrackingon read endpoints. Easy free win, often missed. - Returning unbounded lists. Always page. Your future self will thank you.
Quick recap
- A slow endpoint usually makes too many trips to the database and carries too much data.
- Measure first. Turn on EF Core SQL logging and count the queries before changing anything.
- N+1 is the number one killer. Replace per-row queries with a single projected query.
- Projection (
.Select) reads only the columns you need, in one round trip. AsNoTracking()on read-only queries saves time and memory for free.- Paging with
SkipandTakekeeps endpoints fast as data grows. - Indexes on
WHERE,JOIN, andORDER BYcolumns turn scans into quick lookups. - Compiled queries are a last-resort tweak for only the busiest endpoints.
- Stacking these simple fixes took my endpoint from ~3000 ms to ~200 ms — about 15x faster.
References and further reading
- Efficient Querying - EF Core (Microsoft Learn)
- Advanced Performance Topics - EF Core (Microsoft Learn)
- 10 EF Core Performance Mistakes (and How to Fix Them) in .NET 10 — codewithmukesh
- Five levels of performance tuning for an EF Core query — The Reformed Programmer
- Boost Your EF Core Performance with AsNoTracking() — DEV Community
Related Posts
Named Query Filters in EF Core 10: Multiple Query Filters per Entity
Learn named query filters in EF Core 10. Add multiple global filters per entity, turn off just one by name, and keep soft-delete plus multi-tenant code clean.
Understanding Change Tracking for Better Performance in EF Core
Learn how EF Core change tracking works, the entity states it uses, and simple tricks like AsNoTracking to make your .NET apps faster.
What's New in EF Core 10: LeftJoin and RightJoin in LINQ
Learn the new LeftJoin and RightJoin LINQ operators in EF Core 10. Simple examples, SQL mapping, and clear tables to help you write cleaner join queries.
The Real Cost of Returning the Identity Value in EF Core
Why EF Core asking the database for the new Id after every insert costs round trips, and how HiLo, sequences, and Guids cut that cost down.
EF Core Bulk Insert: Boost Performance with Entity Framework Extensions
Learn how EF Core bulk insert with Entity Framework Extensions saves data faster, using simple examples, diagrams, and clear performance comparisons.
Fast SQL Bulk Inserts With C# and EF Core: A Beginner Guide
Learn fast SQL bulk inserts in C# and EF Core using AddRange, batching, SqlBulkCopy, and bulk libraries, with simple diagrams and clear examples.