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);
}
Prefer:
public async Task<User?> GetUserAsync(int id)
{
return await _repository.GetUserAsync(id);
}
For database calls:
var users = await _context.Users
.ToListAsync();
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;
or:
var users = GetUsersAsync().GetAwaiter().GetResult();
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
...
But your API only needs:
Id
Name
Email
Don't retrieve the entire entity unnecessarily.
Instead of:
var users = await _context.Users
.ToListAsync();
Use projection:
var users = await _context.Users
.Select(u => new UserDto
{
Id = u.Id,
Name = u.Name,
Email = u.Email
})
.ToListAsync();
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
returning 100,000 records.
Instead, support pagination:
GET /api/products?page=1&pageSize=20
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();
}
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();
For read-only operations:
var products = await _context.Products
.AsNoTracking()
.ToListAsync();
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);
}
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();
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();
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);
An index on Email can make this query significantly more efficient.
In EF Core:
modelBuilder.Entity<User>()
.HasIndex(u => u.Email);
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
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();
Then:
app.UseOutputCache();
And on an endpoint:
[OutputCache(Duration = 60)]
[HttpGet("countries")]
public async Task<IActionResult> GetCountries()
{
var countries = await _service.GetCountriesAsync();
return Ok(countries);
}
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();
Then:
app.UseResponseCompression();
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);
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);
Then:
return Ok(new ProductResponse(
product.Id,
product.Name,
product.Price));
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);
}
Instead, use IHttpClientFactory.
Register:
builder.Services.AddHttpClient();
Inject:
public class WeatherService
{
private readonly HttpClient _client;
public WeatherService(IHttpClientFactory factory)
{
_client = factory.CreateClient();
}
}
Then:
public async Task<string> GetWeatherAsync()
{
return await _client.GetStringAsync(
"https://example.com/weather");
}
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);
}
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);
}
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
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
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);
}
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)