DEV Community

Cover image for ASP.NET Core API Performance: 12 Practical Ways to Make Your Web API Faster
ToolBench
ToolBench

Posted on

ASP.NET Core API Performance: 12 Practical Ways to Make Your Web API Faster

A slow API doesn't always mean you need a bigger server.

In many ASP.NET Core applications, performance problems come from things such as inefficient database queries, unnecessary serialization, excessive network calls, missing caching, or returning much more data than the client actually needs.

The good news is that many of these problems can be fixed at the application level.

In this tutorial, we'll look at 12 practical techniques for improving ASP.NET Core Web API performance, with examples you can apply to real projects.


1. Use Async APIs Properly

One of the most important rules in ASP.NET Core is to avoid blocking threads while waiting for I/O operations.

Instead of:

public User GetUser(int id)
{
    return _repository.GetUser(id);
}
Enter fullscreen mode Exit fullscreen mode

Prefer:

public async Task<User?> GetUserAsync(int id)
{
    return await _repository.GetUserAsync(id);
}
Enter fullscreen mode Exit fullscreen mode

For database calls:

var users = await _context.Users
    .ToListAsync();
Enter fullscreen mode Exit fullscreen mode

This allows ASP.NET Core to handle other requests while the application is waiting for the database or another I/O operation.

Avoid this

var users = GetUsersAsync().Result;
Enter fullscreen mode Exit fullscreen mode

or:

var users = GetUsersAsync().GetAwaiter().GetResult();
Enter fullscreen mode Exit fullscreen mode

Blocking asynchronous operations defeats many of the benefits of async programming.


2. Don't Return More Data Than You Need

Imagine your User table contains:

Id
Name
Email
Phone
Address
DateOfBirth
ProfileImage
CreatedDate
UpdatedDate
...
Enter fullscreen mode Exit fullscreen mode

But your API only needs:

Id
Name
Email
Enter fullscreen mode Exit fullscreen mode

Don't retrieve the entire entity unnecessarily.

Instead of:

var users = await _context.Users
    .ToListAsync();
Enter fullscreen mode Exit fullscreen mode

Use projection:

var users = await _context.Users
    .Select(u => new UserDto
    {
        Id = u.Id,
        Name = u.Name,
        Email = u.Email
    })
    .ToListAsync();
Enter fullscreen mode Exit fullscreen mode

This can reduce:

  • Database work
  • Memory usage
  • Network traffic
  • JSON serialization
  • Response size

General rule

Retrieve only the columns you actually need.


3. Use Pagination

Returning thousands of records from an endpoint is rarely a good idea.

Avoid:

GET /api/products
Enter fullscreen mode Exit fullscreen mode

returning 100,000 records.

Instead, support pagination:

GET /api/products?page=1&pageSize=20
Enter fullscreen mode Exit fullscreen mode

Example:

public async Task<List<ProductDto>> GetProducts(
    int page = 1,
    int pageSize = 20)
{
    return await _context.Products
        .AsNoTracking()
        .Skip((page - 1) * pageSize)
        .Take(pageSize)
        .Select(p => new ProductDto
        {
            Id = p.Id,
            Name = p.Name,
            Price = p.Price
        })
        .ToListAsync();
}
Enter fullscreen mode Exit fullscreen mode

Pagination prevents a single request from consuming excessive resources.


4. Use AsNoTracking() for Read-Only Queries

Entity Framework Core tracks entities by default.

Tracking is useful when you plan to modify an entity.

But if you're only reading data, tracking may be unnecessary.

For example:

var products = await _context.Products
    .ToListAsync();
Enter fullscreen mode Exit fullscreen mode

For read-only operations:

var products = await _context.Products
    .AsNoTracking()
    .ToListAsync();
Enter fullscreen mode Exit fullscreen mode

This tells EF Core that you don't need change tracking for these entities.

Good use cases

AsNoTracking() is particularly useful for:

  • GET endpoints
  • Reporting
  • Dashboards
  • Search APIs
  • Read-only queries

5. Avoid N+1 Queries

One of the most common database performance problems is the N+1 query problem.

Imagine this:

var orders = await _context.Orders
    .ToListAsync();

foreach (var order in orders)
{
    var customer = await _context.Customers
        .FindAsync(order.CustomerId);
}
Enter fullscreen mode Exit fullscreen mode

If you have 1,000 orders, this can result in many database queries.

Instead, fetch the required data as part of one query.

For example:

var orders = await _context.Orders
    .Include(o => o.Customer)
    .ToListAsync();
Enter fullscreen mode Exit fullscreen mode

Or, even better when you only need specific fields:

var orders = await _context.Orders
    .Select(o => new OrderDto
    {
        Id = o.Id,
        Total = o.Total,
        CustomerName = o.Customer.Name
    })
    .ToListAsync();
Enter fullscreen mode Exit fullscreen mode

Projection is often preferable when you don't need the complete related entities.


6. Add Database Indexes

Sometimes the API is slow because the database has to scan a large table.

Suppose your API frequently searches:

var user = await _context.Users
    .FirstOrDefaultAsync(u => u.Email == email);
Enter fullscreen mode Exit fullscreen mode

An index on Email can make this query significantly more efficient.

In EF Core:

modelBuilder.Entity<User>()
    .HasIndex(u => u.Email);
Enter fullscreen mode Exit fullscreen mode

You should consider indexes for columns frequently used in:

  • WHERE conditions
  • JOIN conditions
  • ORDER BY
  • UNIQUE lookups

However, don't blindly add indexes to every column.

Indexes also have storage and write-performance costs.


7. Use Response Caching Where Appropriate

Some API responses don't change frequently.

For example:

GET /api/countries
Enter fullscreen mode Exit fullscreen mode

There may be little reason to query the database for every request.

Caching can help.

For example, using ASP.NET Core's output caching:

builder.Services.AddOutputCache();
Enter fullscreen mode Exit fullscreen mode

Then:

app.UseOutputCache();
Enter fullscreen mode Exit fullscreen mode

And on an endpoint:

[OutputCache(Duration = 60)]
[HttpGet("countries")]
public async Task<IActionResult> GetCountries()
{
    var countries = await _service.GetCountriesAsync();

    return Ok(countries);
}
Enter fullscreen mode Exit fullscreen mode

Now repeated requests can be served from the cache instead of repeatedly executing the same application logic.

Good candidates for caching

  • Country lists
  • Product categories
  • Configuration data
  • Public reference data
  • Frequently requested read-only resources

Don't blindly cache user-specific or highly dynamic responses.


8. Compress HTTP Responses

If your API returns large JSON responses, compression can reduce network traffic.

ASP.NET Core supports response compression.

Register it:

builder.Services.AddResponseCompression();
Enter fullscreen mode Exit fullscreen mode

Then:

app.UseResponseCompression();
Enter fullscreen mode Exit fullscreen mode

For example, a large JSON response can become significantly smaller when compressed.

This is particularly useful when:

  • Responses are large
  • Clients have slower connections
  • APIs are accessed over the internet
  • Payloads contain repetitive JSON structures

9. Avoid Unnecessary Serialization

Serialization can become expensive when you're returning large object graphs.

Consider:

return Ok(hugeObject);
Enter fullscreen mode Exit fullscreen mode

If hugeObject contains:

  • Hundreds of properties
  • Nested objects
  • Collections
  • Related entities

the serializer has to process all of them.

Instead, create a focused DTO:

public record ProductResponse(
    int Id,
    string Name,
    decimal Price);
Enter fullscreen mode Exit fullscreen mode

Then:

return Ok(new ProductResponse(
    product.Id,
    product.Name,
    product.Price));
Enter fullscreen mode Exit fullscreen mode

This gives you better control over your API contract and usually reduces payload size.


10. Use HttpClientFactory

If your API calls another API, avoid creating a new HttpClient for every request.

Avoid:

public async Task<string> GetData()
{
    using var client = new HttpClient();

    return await client.GetStringAsync(url);
}
Enter fullscreen mode Exit fullscreen mode

Instead, use IHttpClientFactory.

Register:

builder.Services.AddHttpClient();
Enter fullscreen mode Exit fullscreen mode

Inject:

public class WeatherService
{
    private readonly HttpClient _client;

    public WeatherService(IHttpClientFactory factory)
    {
        _client = factory.CreateClient();
    }
}
Enter fullscreen mode Exit fullscreen mode

Then:

public async Task<string> GetWeatherAsync()
{
    return await _client.GetStringAsync(
        "https://example.com/weather");
}
Enter fullscreen mode Exit fullscreen mode

This allows .NET to manage HTTP connections more effectively.


11. Use Cancellation Tokens

Imagine a user sends an API request and then closes the browser.

If the server continues performing an expensive database operation unnecessarily, resources are wasted.

You can propagate cancellation:

[HttpGet]
public async Task<IActionResult> GetUsers(
    CancellationToken cancellationToken)
{
    var users = await _context.Users
        .AsNoTracking()
        .ToListAsync(cancellationToken);

    return Ok(users);
}
Enter fullscreen mode Exit fullscreen mode

The cancellation token can also be passed through your service layer:

public async Task<List<UserDto>> GetUsersAsync(
    CancellationToken cancellationToken)
{
    return await _context.Users
        .AsNoTracking()
        .Select(u => new UserDto
        {
            Id = u.Id,
            Name = u.Name
        })
        .ToListAsync(cancellationToken);
}
Enter fullscreen mode Exit fullscreen mode

This is especially valuable for expensive operations.


12. Measure Before Optimizing

This might be the most important point.

Don't optimize code simply because it "looks slow."

First identify where the actual bottleneck is.

Measure:

API response time
        ↓
Controller
        ↓
Service
        ↓
Database
        ↓
External APIs
        ↓
Serialization
        ↓
Network
Enter fullscreen mode Exit fullscreen mode

Useful metrics include:

  • Average response time
  • P95 latency
  • P99 latency
  • Database query duration
  • Error rate
  • Request throughput
  • CPU usage
  • Memory usage

For example, an API might have:

Average: 120 ms
P95:     450 ms
P99:     2.4 sec
Enter fullscreen mode Exit fullscreen mode

The average looks reasonable, but the P99 indicates that some requests are significantly slower.

That's why measuring only the average isn't enough.


Putting the Techniques Together

Here's an example of a cleaner, performance-conscious endpoint:

[HttpGet]
public async Task<IActionResult> GetProducts(
    int page = 1,
    int pageSize = 20,
    CancellationToken cancellationToken = default)
{
    var products = await _context.Products
        .AsNoTracking()
        .OrderBy(p => p.Id)
        .Skip((page - 1) * pageSize)
        .Take(pageSize)
        .Select(p => new ProductDto
        {
            Id = p.Id,
            Name = p.Name,
            Price = p.Price
        })
        .ToListAsync(cancellationToken);

    return Ok(products);
}
Enter fullscreen mode Exit fullscreen mode

This small example already applies several performance principles:

  • Async database access
  • Cancellation support
  • No unnecessary change tracking
  • Pagination
  • Projection
  • Deterministic ordering
  • Smaller response objects

A Simple ASP.NET Core Performance Checklist

Before deploying an API, ask:

  • [ ] Are database operations asynchronous?
  • [ ] Are read-only queries using AsNoTracking() where appropriate?
  • [ ] Are API responses paginated?
  • [ ] Are only required columns selected?
  • [ ] Are database indexes appropriate?
  • [ ] Have N+1 queries been eliminated?
  • [ ] Are large responses compressed?
  • [ ] Can frequently requested data be cached?
  • [ ] Are external API calls using IHttpClientFactory?
  • [ ] Are cancellation tokens propagated?
  • [ ] Are response DTOs appropriately sized?
  • [ ] Have performance metrics been measured?

Final Thoughts

ASP.NET Core already provides excellent performance out of the box, but application-level decisions can still make a huge difference.

The biggest improvements often don't come from complicated optimization techniques.

They come from simple decisions:

Query less data.

Make fewer database calls.

Avoid unnecessary work.

Cache what makes sense.

Return smaller responses.

Measure real bottlenecks.

If you build these habits into your development process, your APIs can remain fast and scalable as traffic and data grow.


A Small Developer Tool That Can Save Time

While building and debugging APIs, developers frequently need quick utilities for formatting JSON, comparing API responses, decoding JWTs, converting data formats, and handling other everyday development tasks.

I built ToolBench as a collection of free browser-based developer tools for exactly these small but frequent tasks. Everything runs directly in the browser, making it convenient when you need a quick utility without installing another application.

What is the biggest performance issue you've encountered in an ASP.NET Core API?

Share your experience in the comments — database queries, caching, serialization, external APIs, or something else?

Top comments (0)