Skip to main content
SEMastery
ASP.NETintermediate

How to Increase the Performance of Web APIs in .NET

A friendly, step-by-step guide to making your ASP.NET Core Web APIs fast: async, caching, query tuning, compression, and pooling in .NET 10.

11 min readUpdated May 26, 2026

A kitchen at lunchtime

Imagine a small restaurant at lunchtime. Orders are pouring in. If the cook makes one dish, walks it to the table, comes back, and only then starts the next order, the queue grows huge and customers leave angry.

A smart kitchen works differently. The cook starts the rice boiling and, while it cooks, chops vegetables for another order. Popular dishes are kept ready in a warm tray, so they go out the moment someone asks. The waiter carries several plates in one trip instead of one plate per walk.

A Web API is exactly like that kitchen. Requests are the orders. The database is the slow stove. Your job is to keep the kitchen busy and never let one slow task block everything else. In this guide we will learn the simple, proven ways to make a .NET Web API fast — using clear pictures, small code, and plain English.

We will use .NET 10, which is the current Long-Term Support (LTS) release, and C# 14.

First rule: measure before you change anything

Before you speed up a kitchen, you watch it and find the slowest spot. Maybe the stove is fine but the waiter is slow. Code is the same. Never guess. Measure, find the slowest step, fix it, then measure again.

The optimization loop

Measure
Find slowest step
Fix one thing
Measure again

Steps

1

Measure

Add timing, logs, metrics

2

Find slowest step

Database? Serialization? Network?

3

Fix one thing

Change only that part

4

Measure again

Prove the change helped

Always measure first. Fix the slowest thing. Then measure again to prove it helped.

A good API request usually breaks down into a few stages. Knowing where time goes tells you what to fix.

Figure 1: Where time goes in a typical API request. The database call is very often the slowest part.

In most real APIs, the database / external call stage (D) is the slowest by far. That is why so many of our tips below are about reducing or avoiding that work. Keep this picture in your head as you read.

Tip 1: Use async all the way down

This is the most important habit. When your code waits on the database or the network, it should not hold a thread hostage. Async lets a small pool of threads serve thousands of requests, because waiting threads are freed to do other work.

Think of it like the cook starting the rice and walking away to chop vegetables, instead of standing and staring at the pot.

Figure 2: Blocking holds a thread while waiting. Async frees the thread so it can serve other requests.

The rule is simple: if a method has an async version, use it, and await it. Never call .Result or .Wait() on a task — that blocks the thread and can deadlock your app.

// Slow: blocks a thread the whole time
app.MapGet("/products", (AppDbContext db) =>
{
    var items = db.Products.ToList(); // blocking
    return Results.Ok(items);
});
 
// Fast under load: thread is freed while waiting
app.MapGet("/products", async (AppDbContext db) =>
{
    var items = await db.Products.ToListAsync(); // async
    return Results.Ok(items);
});

Async does not make a single request finish faster. It lets your server handle many more requests at once without running out of threads. Under real traffic, that is the difference between a smooth API and one that falls over.

Tip 2: Cache work you keep repeating

The biggest single win for most APIs is caching. If you save the result of slow work and return the saved copy next time, you skip the slow part entirely. It is the warm tray of ready dishes in our kitchen.

In .NET 10, the recommended modern tool is HybridCache. It combines a fast in-memory layer (L1) with an optional shared store like Redis (L2), and it protects you from a "stampede" — where a popular item expires and hundreds of requests all hit the database at once. With HybridCache, only one request does the slow work; the rest wait for that single result.

// Program.cs
builder.Services.AddHybridCache();
 
// In an endpoint or service
public async Task<Product> GetProductAsync(int id, HybridCache cache)
{
    return await cache.GetOrCreateAsync(
        $"product-{id}",                 // cache key
        async token => await LoadFromDbAsync(id, token),
        cancellationToken: default);
}

The first call is slow (it misses and hits the database). Every call after that is fast until the cached copy expires. For data read far more often than it changes — product details, settings, lookup lists — this is a huge speed-up. Microsoft notes HybridCache can cut database hits by 50 to 90 percent in high-read scenarios.

Here is a quick guide to the caching choices:

Cache typeWhere it livesBest forShared across servers?
IMemoryCacheOne server's RAMSimple single-server appsNo
Distributed (Redis)Separate storeMany servers behind a load balancerYes
HybridCacheRAM + optional RedisNew apps, best of bothYes (with L2)
Output cacheWhole HTTP responseSame response for many usersYes (configurable)

Tip 3: Fix slow database queries

Even with caching, you will hit the database. So make those trips count. The most common mistake is the N+1 query problem: you fetch a list of N items, then loop and run one more query per item. One hundred orders becomes one hundred and one round trips to the database.

N+1 problem vs one good query

Get list
Loop each
Query per item
vs One joined query

Steps

1

Get list

1 query for N orders

2

Loop each

For every order...

3

Query per item

N more queries (slow!)

4

One joined query

Use Include — 1 trip

Loading related data in a loop is slow. Loading it together in one query is fast.

The fix in Entity Framework Core is to load related data together with Include, and to fetch only the columns you actually need.

// Slow: N+1 — one query per order's customer
var orders = await db.Orders.ToListAsync();
foreach (var o in orders)
{
    var name = o.Customer.Name; // triggers a query each time
}
 
// Fast: load related data in one query
var orders = await db.Orders
    .Include(o => o.Customer)
    .Where(o => o.Status == "Open")     // filter in the database
    .Select(o => new OrderDto(           // fetch only needed columns
        o.Id, o.Customer.Name, o.Total))
    .AsNoTracking()                      // read-only, skip change tracking
    .ToListAsync();

Three habits to remember for read-only queries:

  • Filter in the database, not in C# memory. Push your Where to the database so it returns only the rows you need.
  • Select only the columns you use with a small DTO. Do not drag back huge entities you will not read.
  • Use AsNoTracking() for read-only data. It skips EF Core's change-tracking bookkeeping and is faster and lighter.

These three changes often turn a sluggish endpoint into a fast one without any caching at all.

Tip 4: Send smaller responses

A response that is half the size arrives in half the time over a slow network. There are two easy ways to shrink responses.

Compression. Turn on response compression so JSON is squeezed with Brotli (best) or Gzip before it leaves your server. For typical JSON, this cuts size by 60 to 80 percent.

builder.Services.AddResponseCompression(options =>
{
    options.EnableForHttps = true;
    options.Providers.Add<BrotliCompressionProvider>();
    options.Providers.Add<GzipCompressionProvider>();
});
 
var app = builder.Build();
app.UseResponseCompression();

Send less data. Do not return giant lists in one response. Use paging so a request for GET /products returns 20 items, not 20,000. A paged endpoint takes a page number and size, like GET /products?page=2&size=20, and returns only that slice.

TechniqueWhat it doesTypical saving
Brotli / Gzip compressionShrinks the bytes on the wire60–80% smaller JSON
PagingReturns a slice, not everythingHuge on big lists
Slim DTOsReturns only needed fieldsSmaller + clearer
Source-generated JSONFaster serialize, less allocationLower CPU + AOT-ready

For JSON serialization, .NET's System.Text.Json source generator builds the serializer at compile time. That means faster serialization, fewer memory allocations, and it works with Native AOT.

[JsonSerializable(typeof(Product))]
[JsonSerializable(typeof(List<Product>))]
public partial class ApiJsonContext : JsonSerializerContext
{
}
 
// Register it
builder.Services.ConfigureHttpJsonOptions(o =>
    o.SerializerOptions.TypeInfoResolverChain.Insert(0, ApiJsonContext.Default));

Tip 5: Reuse connections — never create them per request

Creating a new HttpClient for every call is like buying a new phone every time you make a call. It is wasteful and can exhaust the operating system's connections (a problem called socket exhaustion). Instead, use IHttpClientFactory, which pools and reuses connections for you.

// Program.cs
builder.Services.AddHttpClient("catalog", c =>
{
    c.BaseAddress = new Uri("https://catalog.example.com");
});
 
// Use it
app.MapGet("/external", async (IHttpClientFactory factory) =>
{
    var client = factory.CreateClient("catalog");
    var data = await client.GetStringAsync("/items");
    return Results.Ok(data);
});

The same idea applies to database connections — EF Core and ADO.NET already pool them for you, so let the pool do its job and do not open connections by hand.

Tip 6: Tune the runtime — turn on Server GC

The .NET garbage collector cleans up memory you no longer use. For a busy server, Server GC is the right mode. It uses multiple threads and is built for throughput, so your API can churn through many requests with shorter pauses. Most server templates enable it already, but it is worth checking your project file.

<PropertyGroup>
  <ServerGarbageCollection>true</ServerGarbageCollection>
  <ConcurrentGarbageCollection>true</ConcurrentGarbageCollection>
</PropertyGroup>

If you publish with Native AOT, your API starts much faster and uses less memory — great for serverless and containers. Just remember AOT needs source-generated JSON (Tip 4), because it cannot use runtime reflection.

Putting it together: the request journey

Here is how a well-tuned API handles a request. Notice how many slow steps it skips.

Figure 3: A tuned request. Cache hits skip the database; misses are cached for next time; the response is compressed on the way out.

A request that hits the cache never touches the database at all. A request that misses does the work once, saves it, and makes every future request fast. On the way out, the response is compressed so it reaches the user quickly. That is the whole game: do slow work as rarely as possible, and send as few bytes as you can.

A note on libraries and licensing

Some popular .NET libraries that you might reach for in API projects have changed their licensing. MediatR and MassTransit are now commercially licensed for many uses. They are fine tools, but you do not need them to build a fast API — plain ASP.NET Core minimal APIs, EF Core, and HybridCache cover everything in this guide for free. Choose libraries with open eyes, and check the license before you depend on one.

Common mistakes that quietly slow you down

  • Blocking on async with .Result or .Wait(). This wastes threads and risks deadlocks. Always await.
  • Returning whole entities with dozens of columns when the client needs three fields. Use a DTO.
  • Logging too much in hot paths. Writing a log line on every loop iteration adds up fast. Log what matters.
  • No paging on list endpoints. A GET /items that returns everything will eventually return too much.
  • Caching data that changes constantly. Caching only helps when data is read far more often than it changes.
  • Optimizing without measuring. You will spend hours speeding up code that was never the problem.

Quick recap

  • Measure first. Find the slowest step, fix that one thing, then measure again.
  • Go async all the way. Use await and the ...Async methods so your server handles many requests at once.
  • Cache repeated work. Use HybridCache in .NET 10 to skip the database for read-heavy data.
  • Fix slow queries. Avoid the N+1 problem; use Include, filter in the database, select only needed columns, and AsNoTracking() for reads.
  • Send smaller responses. Turn on Brotli/Gzip compression, page your lists, and use slim DTOs.
  • Reuse connections. Use IHttpClientFactory; let EF Core pool database connections.
  • Tune the runtime. Enable Server GC; consider Native AOT for fast cold starts.
  • Mind licensing. MediatR and MassTransit are now commercially licensed — you do not need them for a fast API.

References and further reading

Related Posts