Skip to main content
ASP.NET Web Development

Stop Choking Your ASP.NET Core APIs: 7 Performance Killers to Fix Now

Introduction: Why Your ASP.NET Core API Feels SlowYou've deployed your ASP.NET Core API, and users are complaining about slow responses. Maybe you've noticed timeouts under load, or your team's monitoring shows high latency at random intervals. These symptoms are common, but the root causes are often consistent across projects. In this guide, we'll walk through seven performance killers that frequently choke ASP.NET Core APIs. Each section explains the underlying mechanism, shows a concrete exam

Introduction: Why Your ASP.NET Core API Feels Slow

You've deployed your ASP.NET Core API, and users are complaining about slow responses. Maybe you've noticed timeouts under load, or your team's monitoring shows high latency at random intervals. These symptoms are common, but the root causes are often consistent across projects. In this guide, we'll walk through seven performance killers that frequently choke ASP.NET Core APIs. Each section explains the underlying mechanism, shows a concrete example of the mistake, and gives you a fix you can apply today. We'll focus on practical changes—not micro-optimizations that give 1% gains, but structural issues that can cut response times in half. By the end, you'll have a checklist to audit your own API and a better understanding of how to design for performance from the start. This overview reflects widely shared professional practices as of April 2026; verify critical details against current official guidance where applicable.

1. Synchronous Database Queries: The Async Blocking Trap

One of the most common performance killers in ASP.NET Core APIs is using synchronous database calls like .ToList() or .FirstOrDefault() on Entity Framework queries instead of their async counterparts. When a request arrives, ASP.NET Core uses a thread from the thread pool to handle it. If that thread makes a synchronous database call, it blocks entirely, waiting for the database to respond. During that wait, the thread cannot process any other requests. Under load, the thread pool must create more threads, which adds overhead and can lead to thread starvation—a condition where all threads are blocked waiting on I/O, and new requests queue up. This degrades throughput and increases latency dramatically.

Common Mistake: Using Sync Methods in Controllers

A typical example is a controller action that calls _context.Products.ToList() directly. This seems harmless, but it blocks the current thread for the entire duration of the database query. If the query takes 500ms, that thread is tied up for 500ms. In a high-traffic scenario, you might have 100 requests per second, each tying up a thread for half a second, requiring 50 threads concurrently. The thread pool can handle that, but as load increases, thread creation becomes expensive, and context switching adds overhead.

How to Fix: Always Use Async I/O Methods

The fix is simple: use the async versions of all EF Core methods. Replace .ToList() with .ToListAsync(), .FirstOrDefault() with .FirstOrDefaultAsync(), and ensure your action method returns Task<IActionResult>. This allows the thread to be released back to the thread pool while waiting for the database, enabling it to handle other requests. For example: var products = await _context.Products.ToListAsync();. This small change can dramatically improve throughput under concurrent load, often by 2-3x in real-world scenarios. However, be cautious: async doesn't make individual requests faster—it improves scalability by using threads more efficiently.

Additional Considerations: Async All the Way

One pitfall is blocking on async code, such as using .Result or .Wait() on async calls. This can cause deadlocks, especially in environments with a synchronization context like ASP.NET Core (though this is less common in ASP.NET Core than in classic ASP.NET). The rule is simple: async all the way from the controller to the database. If you must call an async method from a synchronous context, consider using ConfigureAwait(false) to avoid capturing the context, but it's better to make the whole call chain async. In practice, teams often find that retrofitting async into an existing codebase requires changing many layers, but the performance gains are worth it. Start with the most critical endpoints—those under highest load—and work outward.

2. Missing Response Caching: Repeated Work on Every Request

Another common performance killer is the absence of caching on endpoints that return data that changes infrequently, such as reference data (lists of countries, product categories, or configuration settings). Without caching, each request triggers a full database query, serialization, and response construction, even though the data hasn't changed. This wastes CPU cycles and database capacity, and increases latency for every request. ASP.NET Core provides several caching mechanisms, from in-memory caching to distributed caches like Redis, and even HTTP response caching via the [ResponseCache] attribute. The key is to identify which data is cacheable and choose the right caching layer.

Common Mistake: No Caching on Static Data Endpoints

Consider an API endpoint that returns a list of supported currencies in an e-commerce application. The currencies change maybe once a year. Yet the controller queries the database on every request, joins to other tables, and serializes the result. This is wasteful and makes the endpoint slower than necessary. A typical pattern is return Ok(await _context.Currencies.ToListAsync()); with no caching. Under load, this can cause unnecessary database pressure and increase response times from 10ms to 200ms, especially if the database is under heavy load from other queries.

How to Implement In-Memory Caching

The simplest fix is to use the built-in IMemoryCache service. In your controller, inject IMemoryCache memoryCache and wrap the data retrieval in a cache check:

if (!memoryCache.TryGetValue("currencies", out List<Currency> currencies)) { currencies = await _context.Currencies.ToListAsync(); memoryCache.Set("currencies", currencies, TimeSpan.FromMinutes(60)); } return Ok(currencies);

This reduces database queries to once per hour. For distributed scenarios, replace IMemoryCache with IDistributedCache (e.g., Redis). You can also use the [ResponseCache] attribute to set cache headers on the HTTP response, allowing intermediate proxies and browsers to cache the response. For example: [ResponseCache(Duration = 3600, Location = ResponseCacheLocation.Any)]. This can offload work from your server entirely.

Choosing the Right Cache Strategy

In-memory caching is fast but not shared across server instances—each instance has its own cache, which can lead to stale data if you have multiple instances without cache invalidation. Distributed caches like Redis are consistent across instances but add network latency. Consider the trade-offs: for single-server deployments, in-memory is fine; for scaled-out deployments, use Redis. Also implement cache invalidation (e.g., when data is updated, remove the cache key) to avoid serving stale data. Many teams use a combination: in-memory for hot data with short TTL, and distributed for shared data with longer TTL.

3. Unbounded Connection Pool Growth: Database Connection Leaks

Database connections are a finite resource. ASP.NET Core's built-in connection pooling (via ADO.NET or Entity Framework) reuses connections to avoid the overhead of establishing new ones for each request. However, if your code does not properly close or dispose of connections, you can exhaust the pool, causing new requests to wait for a connection to become available. This leads to timeouts and degraded performance. The most common cause is not disposing of DbContext instances, or holding connections open longer than necessary. In Entity Framework Core, the DbContext is designed to be short-lived—typically per request. If you use a singleton DbContext (or fail to dispose it), connections remain open indefinitely.

Common Mistake: Singleton DbContext in ASP.NET Core

Some developers register DbContext as a singleton service because they think it's expensive to create. This is a serious anti-pattern. A singleton DbContext is not thread-safe and will cause data corruption under concurrent requests. Even if you use locking, the connection pool will quickly run out because the same connection is held for the lifetime of the application. The proper registration is scoped: services.AddDbContext<AppDbContext>(options => options.UseSqlServer(connectionString), ServiceLifetime.Scoped);. This ensures each request gets a new DbContext instance, and it is disposed at the end of the request, returning the connection to the pool.

How to Diagnose and Fix Connection Pool Exhaustion

Signs of connection pool exhaustion include increased latency, timeouts with error messages like "timeout expired" or "the connection pool has been exhausted", and a high number of active connections in your database monitoring. To fix, first check your DbContext registration. If it's singleton, change to scoped. Second, ensure that you're not manually creating DbContext instances without disposing them. If you use the using statement or rely on DI, disposal is automatic. In some complex scenarios, like background services, you may need to create a new scope using IServiceScopeFactory. Also, monitor the pool size settings: Max Pool Size=100; Min Pool Size=10 in your connection string. Adjust these based on your expected load (e.g., max pool size = number of concurrent requests * average query duration / 1000).

Additional Best Practices for Connection Management

Use async disposal (await using) when possible, especially with Entity Framework Core's DisposeAsync. Avoid long-running transactions that hold connections open. If you need to perform multiple queries, use the same DbContext for the entire request. Also, consider using a connection resiliency strategy (like Polly) to retry transient failures without exhausting the pool. In one composite scenario, a team found that their connection pool was set to the default of 100, but they were hitting 500 requests per second with an average query time of 200ms. This required 100 connections at any given time, leaving no headroom. They increased the max pool size to 200 and optimized query times, which resolved the timeouts.

4. Serialization Overhead: JSON Payloads That Are Too Large

Serialization—converting your C# objects to JSON—is a necessary step in any web API, but it can become a bottleneck if you're serializing more data than needed. The .NET JSON serializer (System.Text.Json) is fast, but it still takes time proportional to the size of the object graph. Returning large, nested objects with many properties that the client doesn't need wastes CPU cycles, memory, and bandwidth. This increases latency and reduces throughput. The problem is often caused by returning full entity objects directly from Entity Framework, including navigation properties and fields that are irrelevant to the client.

Common Mistake: Returning EF Core Entities Directly

Consider a Product entity with properties like Id, Name, Description, Price, CategoryId, Category navigation property, SupplierId, Supplier navigation property, CreatedAt, UpdatedAt, and InternalNotes. If you return this entity directly from a controller action, the serializer will include all properties, including navigation properties that may trigger lazy loading (another performance killer). The resulting JSON could be several kilobytes, even though the client only needs Id, Name, and Price. This not only slows down the API but also increases the response size, affecting network latency and client rendering.

How to Use DTOs and AutoMapper

The solution is to create Data Transfer Objects (DTOs) that contain only the properties the client needs. For the product example, create a ProductDto with Id, Name, and Price. Then, in your controller, project the query to the DTO using Select() or use AutoMapper to convert entities to DTOs. For instance: var products = await _context.Products.Select(p => new ProductDto { Id = p.Id, Name = p.Name, Price = p.Price }).ToListAsync();. This reduces both the database query (fewer columns fetched) and the serialization payload (fewer properties). It also avoids lazy loading because you are not accessing navigation properties.

Serialization Configuration and Compression

In addition to trimming payloads, configure System.Text.Json to exclude null values, use camelCase naming (default), and set a maximum depth for object graphs. You can use JsonSerializerOptions with DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull. For large responses, consider enabling response compression via the Microsoft.AspNetCore.ResponseCompression middleware. This reduces the size of the payload over the network, especially for text-heavy JSON. However, compression adds CPU overhead, so test on your production workload. In many cases, compressing JSON can reduce payload size by 70-80%, which can significantly improve perceived performance for clients with slow connections.

5. Inefficient LINQ Queries: The N+1 Query Problem

Entity Framework Core is an ORM that abstracts database access, but it can generate inefficient SQL if your LINQ queries are not written carefully. The most notorious issue is the N+1 query problem: when you load a parent entity and then lazy-load related child entities in a loop, each child triggers a separate database query. For example, fetching a list of orders and then iterating over each order to access its line items results in 1 query for orders + N queries for line items, where N is the number of orders. This can devastate performance, especially with large datasets. Even without lazy loading, many LINQ queries can cause unnecessary round trips or fetch too much data.

Common Mistake: Lazy Loading Navigation Properties in a Loop

Imagine a controller that returns all customers and their orders. The code might look like this:

var customers = await _context.Customers.ToListAsync(); foreach (var customer in customers) { foreach (var order in customer.Orders) // triggers lazy load { // process order } }

This executes one query for customers, then a separate query for orders per customer. If there are 1000 customers, that's 1001 queries. This is catastrophic for performance. The fix is to use eager loading with .Include() or .ThenInclude() to load related data in a single query: var customers = await _context.Customers.Include(c => c.Orders).ToListAsync();. This generates a JOIN and retrieves all data in one round trip.

Using Projection to Avoid Overfetching

Even with eager loading, you may still fetch more columns than needed. The best approach is to project to a DTO or anonymous type using Select(). This not only avoids the N+1 problem but also reduces the amount of data transferred from the database. For example: var result = await _context.Customers.Select(c => new { c.Id, c.Name, Orders = c.Orders.Select(o => new { o.Id, o.Total }) }).ToListAsync();. This generates a SQL query that selects only the required columns and uses joins efficiently. It also prevents lazy loading because you are not accessing navigation properties after the query.

Monitoring and Fixing Inefficient Queries

Use the logging capabilities of EF Core to see the generated SQL. Set optionsBuilder.LogTo(Console.WriteLine, LogLevel.Information) in your DbContext configuration to output SQL to the console. Look for multiple queries in a short time frame that indicate N+1. Also, use profiling tools like MiniProfiler or the built-in Application Insights to detect slow queries. In one composite scenario, a team noticed that a single API endpoint was executing 200+ queries per request. After switching to eager loading and projection, the number dropped to 3, and the endpoint's response time dropped from 5 seconds to 200ms. The key is to think about the SQL that EF Core will generate and write LINQ accordingly.

6. Thread Starvation from Blocking Calls in Async Controllers

ASP.NET Core is built for asynchronous I/O, but many codebases contain blocking calls that defeat the purpose. When you call .Result or .Wait() on a Task, or use Task.Run() to simulate async, you block the current thread. In a web application, this ties up a thread pool thread while it waits, reducing the number of threads available to handle other requests. Under high load, this can lead to thread starvation, where the thread pool is exhausted and requests queue up, causing timeouts. This is often seen when calling async methods from synchronous context, or when using libraries that don't provide async interfaces.

Common Mistake: Blocking on Async Methods

A typical example is in a controller action that is synchronous but calls an async method: public IActionResult Get() { var data = _service.GetDataAsync().Result; return Ok(data); }. This blocks the current thread until the task completes. If the task takes 1 second, the thread is blocked for 1 second. During that time, it cannot handle other requests. In a high-throughput scenario, this can cause thread pool exhaustion. The fix is to make the controller action async: public async Task<IActionResult> Get() { var data = await _service.GetDataAsync(); return Ok(data); }. This releases the thread while waiting.

When You Can't Go Async: Workarounds

Sometimes you must call an async method from a synchronous context, such as in a constructor or a property getter. In those cases, you can use GetAwaiter().GetResult() (which is similar to .Result but can throw exceptions differently) or configure the task to not capture the synchronization context. However, these are last resorts and can still cause deadlocks in some environments. The best practice is to make the whole call chain async. If you're using a library that only offers synchronous methods, consider wrapping it in a background queue or using Task.Run() to offload it to a thread pool thread, but be aware that this still uses a thread, just not the request thread. In ASP.NET Core, avoid Task.Run() for I/O-bound work because it creates unnecessary thread pool usage.

Identifying Blocking Calls in Your Codebase

Use static analysis tools like Roslyn analyzers to detect blocking calls. There is a built-in analyzer for ASP.NET Core that warns when you use .Result or .Wait() in async methods. Also, review your code for any usage of Thread.Sleep(), which should be replaced with Task.Delay(). In one team's experience, they found that a third-party logging library was using .Result internally, causing severe performance degradation. They switched to a fully async logging library and saw a 40% improvement in throughput under load. The lesson: trust but verify—every blocking call is a potential performance killer.

7. Missing Rate Limiting and Throttling: Overwhelming the Server

Even with optimized code, an API can be overwhelmed by too many requests from a single client or a sudden spike in traffic. Without rate limiting, a misbehaving client or a DDoS attack can consume all your server resources, causing the API to become unresponsive for legitimate users. Rate limiting is a mechanism to control the number of requests a client can make in a given time window. ASP.NET Core does not include built-in rate limiting until .NET 7, but earlier versions can use middleware or third-party libraries like AspNetCoreRateLimit. Implementing rate limiting protects your API from abuse and ensures fair resource distribution.

Common Mistake: No Rate Limiting on Public Endpoints

A common scenario is a public API that accepts user registration or search queries. Without rate limiting, an attacker can send thousands of requests per second, causing database overload and high CPU usage. Even unintentionally, a mobile app with a bug could retry requests rapidly, causing a self-inflicted DDoS. The fix is to add rate limiting middleware that tracks requests per client (by IP, API key, or user identity) and returns HTTP 429 (Too Many Requests) when the limit is exceeded. This queues or rejects excess requests, protecting the server.

Share this article:

Comments (0)

No comments yet. Be the first to comment!