An ASP.NET Core API can have normal CPU and memory usage and still feel slow.
That is one of the more frustrating API performance problems to troubleshoot.
You check the server. CPU looks fine. Memory looks fine. The database is available. There are no obvious infrastructure failures.
Yet an endpoint that should respond in 100–200 ms is taking 1–2 seconds.
The reason is that API latency is not determined by CPU and memory alone.
A request can spend most of its time waiting for a database query, another API, a thread, a lock, serialization, or some application code that is doing more work than expected.
The first question I ask when troubleshooting a slow ASP.NET Core API is:
Where is the request actually spending its time?
This article walks through 10 common ASP.NET Core performance bottlenecks and, more importantly, how to identify them before changing the code.
1. Start With Measurement, Not Optimization
Before changing a LINQ query, adding caching, or increasing server resources, establish where the latency comes from.
A useful way to think about an API request is:
Total Request Time
|
+-- Application code
+-- Database
+-- External APIs
+-- Serialization
+-- Network
+-- Middleware
+-- Thread scheduling / blockingFor example, suppose an endpoint takes 1.4 seconds:
Application code 120 ms
SQL queries 180 ms
External API 900 ms
JSON serialization 80 ms
Other 120 ms
--------------------------------
Total 1400 msOptimizing the 180 ms database operation will not solve the main problem.
The external API is responsible for most of the latency.
This is why performance troubleshooting should generally follow:
Measure → Trace → Identify → Fix → Measure again
Useful metrics include:
Average response time
P95 latency
P99 latency
Requests per second
Database query duration
Number of database calls
External dependency latency
Exception rate
Thread-pool behavior
Response payload size
Average latency is useful, but P95 and P99 can reveal problems that average measurements hide.
2. Slow SQL Queries
The database is one of the first places to investigate when an ASP.NET Core API becomes slow.
A query that performs well with 10,000 rows in development may behave very differently when the production database contains millions of records.
Common causes include:
Missing indexes
Inefficient joins
Full table scans
Large result sets
Selecting unnecessary columns
Poorly written LINQ queries
Repeated database calls
Expensive execution plans
For example:
var customers = await db.Customers
.Where(x => x.Status == "Active")
.ToListAsync();The LINQ itself doesn't tell you whether the generated SQL is efficient.
You need to inspect the SQL and execution plan.
With EF Core, you can inspect generated SQL during development:
var query = db.Customers
.Where(x => x.Status == "Active");
Console.WriteLine(query.ToQueryString());Then check the actual database behavior.
Look at:
Execution plan
Index usage
Query duration
Number of rows scanned
Number of rows returned
Database round trips
A useful rule is:
Don't optimize the C# code around a slow query until you know how much time the database is actually consuming.
3. EF Core N+1 Queries
The N+1 query problem is particularly easy to introduce when working with Entity Framework Core.
Imagine an endpoint retrieves 100 customers:
1 query → Get 100 customers
100 queries → Get details for each customer
Total = 101 database queriesThe API may work correctly, but the number of database round trips can quickly become a performance problem.
A simplified example looks like this:
var customers = await db.Customers
.ToListAsync();
foreach (var customer in customers)
{
var orders = await db.Orders
.Where(x => x.CustomerId == customer.Id)
.ToListAsync();
}With 100 customers, this can result in 101 database calls.
One approach is to project only the data required by the API:
var customers = await db.Customers
.Select(c => new CustomerDto
{
Id = c.Id,
Name = c.Name,
Email = c.Email
})
.ToListAsync();Depending on the relationship and use case, eager loading, projections, joins, or specifically designed queries may be appropriate.
The important part is not simply changing the LINQ.
Measure the generated SQL and verify how many database calls the endpoint actually makes.
4. Blocking Calls and Thread Pool Starvation
ASP.NET Core is designed around asynchronous I/O, but blocking calls can still appear in application code.
For example:
var result = service.GetDataAsync().Result;or:
service.GetDataAsync().Wait();These calls can block a thread while waiting for I/O.
Under light traffic, the problem may not be obvious.
Under higher concurrency, blocked threads can accumulate and contribute to thread-pool starvation.
Possible symptoms include:
Increasing response times
Requests waiting in queues
Reduced throughput
Timeouts
Increasing thread counts
CPU usage that does not necessarily look high
Prefer asynchronous code throughout the request path:
var result = await service.GetDataAsync();Also look for:
.Result
.Wait()
Blocking locks
Synchronous I/O
Blocking third-party librariesFor runtime investigation, tools such as dotnet-counters and dotnet-trace can help identify thread-pool and runtime behavior.
The important point is that low CPU utilization does not automatically mean the application is healthy.
An application can be waiting rather than computing.
5. Slow External API Calls
Your ASP.NET Core application may be fast while one of its dependencies is slow.
Consider an endpoint that calls three services:
Customer API 150 ms
Payment API 300 ms
Shipping API 800 ms
--------------------------------
Total 1,250 msYour own application code may execute in only a small fraction of that time.
The user still waits 1.25 seconds.
This is why distributed tracing becomes important in applications with multiple dependencies.
For outbound HTTP calls, consider:
IHttpClientFactoryAsync HTTP calls
Connection reuse
Appropriate timeouts
Limited retries
Circuit breakers
Caching where appropriate
Distributed tracing
For example:
public async Task<Customer> GetCustomerAsync(
int customerId,
CancellationToken cancellationToken)
{
return await httpClient.GetFromJsonAsync<Customer>(
$"customers/{customerId}",
cancellationToken);
}
Join the conversation! Your thoughts help the community grow.