When a .NET API starts slowing down under load, the root cause is rarely a single bottleneck. More often, it's a combination of small, seemingly harmless patterns that compound into poor performance. Teams we've worked with have traced latency spikes to async/await misuse, oversized payloads, chatty endpoints, and connection pool starvation — all fixable once you know what to look for. This guide covers six modern pitfalls that commonly hurt .NET API performance and shows how to fix each one. We'll focus on practical steps you can apply today, without relying on third-party tools or magic bullets.
1. The Sync-over-Async Trap: Why Blocking Calls Kill Throughput
One of the most common mistakes in modern .NET APIs is calling synchronous methods inside asynchronous code paths. This happens when a developer uses .Result or .Wait() on a Task, or calls a synchronous method like DbContext.SaveChanges() instead of SaveChangesAsync() in an async controller action. The problem is that blocking a thread in an async context ties up a thread pool thread, preventing it from handling other requests. Under load, this leads to thread pool starvation, where the API becomes unresponsive even though the CPU is idle.
Why It Hurts
When you block on an async call, you force the thread to wait for I/O completion. The thread cannot return to the pool to serve other requests. As more requests arrive, the thread pool injects additional threads to compensate, which increases context switching and memory pressure. Eventually, the API hits a throughput ceiling far below its theoretical limit. In a typical ASP.NET Core application, a single sync-over-async call can reduce throughput by 50% or more under moderate load.
How to Detect It
Look for .Result, .Wait(), .GetAwaiter().GetResult(), or synchronous EF Core methods in async controllers or middleware. Also check for libraries that expose only sync methods — they may force you into this pattern. Profiling tools like PerfView or dotnet-counters can show high thread injection rates or large thread pool queue lengths.
How to Fix It
Use async all the way down. Replace .Result with await. Use DbContext.SaveChangesAsync(), HttpClient.SendAsync(), and other async equivalents. If you need to call a synchronous method, consider offloading it to a background thread with Task.Run only as a last resort — but prefer finding an async alternative. For libraries that lack async support, wrap the call in Task.Run only if the operation is CPU-bound and short; for I/O-bound sync calls, it's better to migrate the library itself.
2. Oversized Responses: The Hidden Cost of Serialization and Bandwidth
Modern APIs often return more data than clients need. This is especially common when using Entity Framework Core with .Include() and .ThenInclude() without projection, or when serializing entire domain objects. Large JSON payloads increase serialization time, consume network bandwidth, and slow down client-side parsing. In mobile or low-bandwidth scenarios, this can add seconds of latency.
Why It Hurts
Serialization is CPU-intensive, especially for complex object graphs. Returning 100 KB of JSON when the client only needs 10 KB means you waste 90% of serialization effort and bandwidth. Additionally, large responses increase memory allocations and put pressure on the garbage collector. Over time, this can lead to higher GC pauses and reduced throughput.
How to Detect It
Measure response sizes with tools like Fiddler, curl, or application insights. Look for endpoints returning >50 KB per request. Also check for repeated serialization of the same data — caching can help. Use middleware to log response sizes during development.
How to Fix It
Use DTOs (Data Transfer Objects) to return only the fields the client needs. In EF Core, use .Select() to project to a DTO instead of loading full entities. For read-heavy endpoints, consider using raw SQL or a micro-ORM like Dapper for leaner queries. Enable response compression (Brotli or Gzip) in ASP.NET Core to reduce payload size over the wire. Also consider pagination or field selection via query parameters (e.g., ?fields=id,name).
3. Chatty Endpoints: Too Many Round Trips
Another frequent pitfall is designing APIs that require multiple requests to accomplish a single logical operation. For example, a client might call GET /users then make separate requests for each user's orders. This increases latency due to network overhead and connection setup. Under load, chatty patterns can overwhelm the server with connection management.
Why It Hurts
Each HTTP request incurs overhead: DNS resolution, TCP handshake, TLS negotiation (if HTTPS), request/response headers, and connection pooling. Even with keep-alive, the cumulative latency of many small requests can dwarf the actual processing time. Additionally, many small requests increase the load on the server's connection pool and can lead to thread pool contention.
How to Detect It
Review client-server interaction patterns. Look for sequences of requests where one request depends on data from a previous one. Use browser dev tools or server-side logging to count requests per page view or per user action. A ratio of >10 requests per logical operation is a red flag.
How to Fix It
Consolidate data into fewer endpoints. Use GraphQL for flexible queries, or implement a BFF (Backend for Frontend) pattern that aggregates data server-side. Alternatively, create a composite endpoint that returns all necessary data in one call. For example, instead of GET /users/1 and GET /users/1/orders, create GET /users/1?include=orders. Also consider using HTTP/2 multiplexing to reduce connection overhead, but that doesn't fix the fundamental chattiness.
4. Poor Caching Strategies: When Caching Makes Things Worse
Caching seems like an easy win, but misapplied caching can actually hurt performance. Common mistakes include caching too much (memory pressure), caching too little (misses), using the wrong cache location, or not invalidating stale data. In-memory caching on a single server can cause consistency issues when scaling out, while distributed caches add network latency.
Why It Hurts
Excessive in-memory caching consumes RAM, leading to garbage collection pressure and potential out-of-memory exceptions. On the other hand, a cache with a low hit rate adds overhead without benefit — every request pays the cost of checking the cache, but few find data. Improper invalidation can serve stale data, forcing clients to implement their own workarounds, which often involve more requests.
How to Detect It
Monitor cache hit rates. A hit rate below 80% may indicate poor cache key design or too short TTL. Also monitor memory usage per server. Use application performance monitoring (APM) to track cache-related latency. If cache checks are adding 5 ms but only 10% of requests hit, the net effect is negative.
How to Fix It
Cache only data that is expensive to compute and relatively stable. Use a distributed cache (Redis) for shared state across instances. Set appropriate TTLs based on data freshness requirements. Implement cache invalidation via event-driven patterns (e.g., publish/subscribe) when underlying data changes. Consider using response caching middleware for idempotent GET endpoints, but be careful with authenticated requests. Also, use cache-aside pattern with fallback to database.
5. Connection Pool Starvation: When Database Connections Run Out
ASP.NET Core uses a connection pool for database connections, but misconfiguring the pool or holding connections too long can exhaust it. This happens when you open connections but don't dispose them promptly, or when you use synchronous database calls in async code (which blocks threads and holds connections longer). The result is timeouts and failed requests.
Why It Hurts
Each open connection consumes a slot in the pool. If the pool is exhausted, new requests block waiting for a connection to become available. This increases latency and can cascade into timeouts. In extreme cases, the application becomes completely unresponsive to database-dependent requests. Connection pool exhaustion is often a symptom of other issues like sync-over-async or long-running transactions.
How to Detect It
Look for System.InvalidOperationException with messages about timeout expired or pool exhaustion. Monitor the number of active connections using sp_who2 (SQL Server) or pg_stat_activity (PostgreSQL). In .NET, use performance counters like NumberOfActiveConnections from the SqlConnection pool.
How to Fix It
Ensure all database connections are disposed promptly — use using statements or await using for async. Keep transactions short and avoid holding connections across multiple await calls. Increase the max pool size if necessary (default is 100), but first address the root cause of long-held connections. Use async database methods (OpenAsync, ExecuteReaderAsync) to avoid blocking. Also consider using connection multiplexing libraries like Npgsql's built-in pooling.
6. Ignoring Modern Serialization Options: Newtonsoft.Json vs System.Text.Json
Many .NET APIs still use Newtonsoft.Json (Json.NET) by default, even though ASP.NET Core 3.0+ includes System.Text.Json, which is faster and more memory-efficient. While Newtonsoft.Json is feature-rich, its default settings can be slower, especially for large payloads. Additionally, not configuring serialization options (like ignoring null values or using camelCase) can lead to bloated responses.
Why It Hurts
System.Text.Json is designed for performance: it uses Utf8JsonReader and Utf8JsonWriter to avoid string allocations, and it supports async serialization. Benchmarks show it can be 2–3x faster than Newtonsoft.Json for typical scenarios. Using the slower library unnecessarily adds CPU time and memory pressure. Also, serializing null fields adds bytes to every response.
How to Detect It
Check the project file for a reference to Newtonsoft.Json or Microsoft.AspNetCore.Mvc.NewtonsoftJson. If present, you're likely using the older serializer. Profile serialization time using a tool like BenchmarkDotNet — compare the same payload with both libraries.
How to Fix It
Migrate to System.Text.Json. In ASP.NET Core 3.0+, it's the default. Remove the Newtonsoft.Json package and update startup configuration. Configure options like PropertyNamingPolicy = JsonNamingPolicy.CamelCase and DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull to reduce payload size. For compatibility with existing Newtonsoft.Json attributes, use JsonSerializerOptions with PropertyNameCaseInsensitive = true. If you need features like ReferenceLoopHandling, consider using System.Text.Json's ReferenceHandler.
Frequently Asked Questions
What is the biggest performance killer in .NET APIs?
In our experience, sync-over-async is the most damaging because it directly reduces thread pool efficiency and can cause cascading failures under load. However, the answer depends on your specific workload — for I/O-heavy APIs, it's often the top issue; for compute-heavy ones, serialization or caching may be more critical.
Should I always use async methods in my API controllers?
Yes, for any I/O-bound operation (database calls, HTTP calls, file system access). For CPU-bound operations, async doesn't help and may add overhead. But in practice, most API endpoints do some I/O, so async is recommended. Just avoid mixing sync and async.
How do I choose between in-memory and distributed caching?
Use in-memory caching for single-instance deployments or for data that doesn't need to be shared. Use distributed caching (Redis, SQL Server) when you have multiple instances and need consistency. Also consider hybrid approaches: cache locally with a short TTL and use distributed cache as a fallback.
Is it worth migrating from Newtonsoft.Json to System.Text.Json?
For new projects, absolutely — start with System.Text.Json. For existing projects, weigh the migration effort against performance gains. If your API handles large payloads or high throughput, the migration can yield significant improvements. Use the System.Text.Json compatibility package if you need to preserve some Newtonsoft.Json attributes.
Final Recommendations
To wrap up, here are three specific actions you can take this week to improve your .NET API's performance:
First, audit your codebase for sync-over-async patterns. Search for .Result, .Wait(), and .GetAwaiter().GetResult() in async methods. Fix each occurrence by making the call chain fully async. This single change often yields the biggest improvement.
Second, enable response compression in your ASP.NET Core middleware. Add services.AddResponseCompression() and app.UseResponseCompression() in your startup. This reduces payload size by up to 70% with minimal CPU cost.
Third, review your database connection pooling settings. Ensure you're using async methods and disposing connections quickly. If you see timeout errors, increase the pool size temporarily, but investigate the root cause. Consider using a connection monitoring tool to track open connections.
Performance tuning is an ongoing process — start with these fixes, measure the impact, and iterate. Your users will notice the difference.
Comments (0)
Please sign in to post a comment.
Don't have an account? Create one
No comments yet. Be the first to comment!