Skip to main content
SEMastery
Fundamentalsbeginner

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.

12 min readUpdated October 16, 2025

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 slow way makes many trips to the market; the fast way makes one.

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 SQL

When 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

Reproduce
Measure
Find worst spot
Fix one thing
Measure again

Steps

1

Reproduce

Hit the slow endpoint locally

2

Measure

Turn on SQL logging and a timer

3

Find worst spot

Count queries and time

4

Fix one thing

Change a single cause

5

Measure again

Prove it actually helped

I never change code until a measurement tells me where the pain is.

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.

N+1 turns one request into many round trips to the database.

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:

ApproachColumns readRows in memoryRound trips
Full entities + loopAll columns50 orders + 50 customers52
Projection (.Select)Only 4 fields50 small DTOs1

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().

Tracked queries keep an extra copy of every row; no-tracking queries do not.

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 OrderByDescending

A 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 madeApprox. timeQueries per request
Original (N+1 loop)~3000 ms52
Projection in one query~700 ms1
Added AsNoTracking~520 ms1
Added paging (20 rows)~260 ms1
Added index on CreatedAt~200 ms1

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

Measure
Kill N+1
Project
No tracking
Page
Index
Compile (rare)

Steps

1

Measure

Log SQL and time the request

2

Kill N+1

One query, not one per row

3

Project

Select only needed fields

4

No tracking

AsNoTracking for read-only

5

Page

Skip and Take a small slice

6

Index

Index WHERE/JOIN/ORDER BY columns

7

Compile (rare)

Only the hottest endpoints

Apply these in order. Most endpoints only need the first three.

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 Include for 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 AsNoTracking on 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 Skip and Take keeps endpoints fast as data grows.
  • Indexes on WHERE, JOIN, and ORDER BY columns 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

Related Posts