Modern .NET APIs are expected to handle thousands of requests per second with sub-100ms response times. Yet many teams unknowingly bake in anti-patterns that turn a well-architected system into a slow, resource-hungry mess. These patterns often start as shortcuts: a quick endpoint to return everything, a synchronous call in an async method, or a database query that grows with every feature request. Over time, they compound, and performance degrades until users notice.
This guide identifies six of the most damaging anti-patterns we've seen in .NET APIs, explains the mechanics behind the slowdown, and offers direct replacements. The goal is not just to list problems, but to give you concrete steps to fix them—starting with your next sprint.
1. The Chatty API: Too Many Round Trips
One of the most common performance killers is an API that forces clients to make multiple calls to assemble a single view. For example, a mobile app might call /users/{id}, then /users/{id}/orders, then /orders/{id}/items just to show a user's order summary. Each round trip adds latency from network overhead, TLS handshakes, and HTTP framing. Even with HTTP/2 multiplexing, the sheer number of requests can overwhelm connection limits and increase server load.
Why It Hurts
Every HTTP request carries fixed overhead: DNS resolution, connection setup, request/response serialization, and middleware processing. If your API requires 10 calls to render a page, that overhead is multiplied by 10. On mobile networks, where latency is higher, the impact is even worse. Users experience slow load times, and your server spends more time handling connections than doing useful work.
How to Detect It
Look at your client-side code or network logs. If you see sequential API calls where the response of one is used to call another, you likely have a chatty API. Also check for endpoints that return only IDs, forcing the client to fetch related data separately.
The Fix: Consolidate with GraphQL or Aggregation Endpoints
Instead of forcing clients to orchestrate multiple calls, design endpoints that return exactly what the client needs. For simple scenarios, create a dedicated aggregation endpoint (e.g., GET /user-summary/{id} that returns user info, recent orders, and order items in one response). For more complex or varying needs, consider GraphQL, which lets clients specify their exact data requirements. In .NET, Hot Chocolate or GraphQL.NET make this straightforward.
Another approach is to use OData with $expand to allow clients to include related entities in a single request. However, be cautious with OData's flexibility—it can lead to performance issues if clients request deeply nested data. Always set limits on expansion depth.
2. The Kitchen Sink Endpoint: Returning Everything
An endpoint that returns all fields of an entity—including large text fields, binary data, or nested objects—wastes bandwidth and serialization time. This is especially common in early API designs where developers expose the full database model. The result is payloads that are 10x larger than needed, slowing down both the server (more data to serialize) and the client (more data to parse).
Why It Hurts
Serialization is CPU-intensive, and network transfer time scales with payload size. A 100 KB response takes longer to serialize, transmit, and deserialize than a 10 KB one. Over many requests, this adds up to significant latency and server load. Additionally, large responses consume more memory in the server's output buffer and the client's heap.
How to Detect It
Profile your API responses. If an endpoint returns fields that the client never uses (e.g., CreatedAt, UpdatedAt, InternalNotes), you have a kitchen sink. Also check for large JSON arrays that include every column from a database table.
The Fix: Use Projections and DTOs
Create Data Transfer Objects (DTOs) that contain only the fields needed for a specific use case. In Entity Framework Core, use .Select() to project to the DTO directly in the query, avoiding loading full entities. For example:
public async Task<UserSummaryDto> GetUserSummary(int id)
{
return await _context.Users
.Where(u => u.Id == id)
.Select(u => new UserSummaryDto
{
Name = u.Name,
Email = u.Email,
OrderCount = u.Orders.Count
})
.FirstOrDefaultAsync();
}This reduces payload size and database load simultaneously. For read-only endpoints, consider using raw SQL or views for even better performance.
3. Synchronous Blocking in Async Controllers
ASP.NET Core is built on async I/O. When you use .Result or .Wait() on a Task inside an async controller, you block the thread pool thread, defeating the purpose of async. This can lead to thread pool starvation under load, where all threads are blocked waiting on I/O, and new requests queue up.
Why It Hurts
When a thread blocks on .Result, it cannot handle other requests. If the I/O operation takes 500ms, that thread is idle for 500ms. Under high concurrency, the thread pool may exhaust, causing requests to wait or time out. This is a classic scalability anti-pattern.
How to Detect It
Search your codebase for .Result, .Wait(), and .GetAwaiter().GetResult() in controller actions or service methods called by controllers. Also look for Task.Run() in synchronous methods that wrap I/O—it's often a sign of misunderstanding.
The Fix: Async All the Way
Make your controllers, services, and data access methods async all the way up. Use await instead of blocking. If you need to call a synchronous method, consider using Task.Run() only for CPU-bound work, not I/O. For libraries that don't offer async methods, look for async alternatives or wrap them carefully with Task.Run (but prefer async-native libraries).
Also avoid Task.Result in property getters or constructors—those should not block. Instead, use lazy initialization or async factory methods.
4. N+1 Query Pattern in Entity Framework
The N+1 problem occurs when you load a parent entity and then iterate over its children, triggering a separate query for each child. For example, loading all orders and then accessing each order's items in a loop. This results in 1 query for the parent + N queries for the children, where N can be large.
Why It Hurts
Each query involves database round trips, query parsing, and result materialization. With 100 orders, you get 101 queries instead of 1. This can easily saturate the database connection pool and increase response time from milliseconds to seconds.
How to Detect It
Enable EF Core logging to see the generated SQL. If you see many identical queries with different IDs, you have N+1. Also look for Include() calls that are missing or for lazy loading enabled in a loop.
The Fix: Eager Loading or Projection
Use .Include() to load related data in a single query with JOINs. For example:
var orders = await _context.Orders
.Include(o => o.Items)
.ToListAsync();Alternatively, use projection with .Select() to load only the needed data, which often avoids the N+1 problem entirely. For complex scenarios, consider using .ThenInclude() for nested relationships. Disable lazy loading in production to prevent accidental N+1.
5. Unbounded Result Sets
APIs that return all records from a table without pagination are a disaster waiting to happen. As the dataset grows, response times increase, memory usage spikes, and the client may crash trying to parse a huge JSON array.
Why It Hurts
Fetching 100,000 rows from the database and serializing them into a single response consumes server memory, CPU, and network bandwidth. The client must deserialize the entire payload before it can display anything, leading to poor user experience. This also makes the API vulnerable to accidental or malicious large requests.
How to Detect It
Check endpoints that return lists—do they accept page and pageSize parameters? If not, they likely return unbounded results. Also look for .ToListAsync() without a .Take().
The Fix: Implement Pagination
Add mandatory pagination parameters. Use keyset pagination (cursor-based) for large datasets to avoid the offset performance penalty. In EF Core, implement like this:
public async Task<PagedResult<OrderDto>> GetOrders(int page, int pageSize)
{
var query = _context.Orders
.OrderBy(o => o.Id)
.Skip((page - 1) * pageSize)
.Take(pageSize);
var items = await query.Select(o => new OrderDto { ... }).ToListAsync();
var total = await _context.Orders.CountAsync();
return new PagedResult<OrderDto> { Items = items, Total = total };
}For high-traffic endpoints, prefer cursor-based pagination using a unique key (e.g., lastSeenId) to avoid the OFFSET slowdown on large skip values.
6. Overuse of AutoMapper Without Explicit Mapping
AutoMapper can simplify mapping between entities and DTOs, but when overused or misconfigured, it introduces hidden performance costs. Reflection-based mapping, especially in tight loops, can be orders of magnitude slower than manual mapping. Additionally, complex mappings with custom resolvers can lead to unexpected N+1 queries.
Why It Hurts
AutoMapper's ProjectTo is efficient because it works with IQueryable, but using Map on in-memory collections forces reflection and can be slow. If you call Map on a list of 10,000 items, the overhead is significant. Also, poorly configured mappings may trigger lazy loading or extra database queries.
How to Detect It
Profile your code. If you see high CPU usage in AutoMapper's mapping logic, or if you have complex mappings with ForMember and custom resolvers, consider whether manual mapping would be simpler. Also check for .Map calls after .ToList() instead of using ProjectTo.
The Fix: Use ProjectTo or Manual Mapping
For read operations, use ProjectTo with AutoMapper to map directly in the database query. This avoids loading full entities and reduces memory usage. For write operations or simple mappings, consider manual mapping with a helper method—it's often faster and easier to debug. Reserve AutoMapper for complex mappings that change frequently and where the configuration overhead is justified.
If you do use AutoMapper, configure it once at startup and avoid runtime CreateMap calls. Use IMapper via dependency injection rather than the static Mapper.Map.
Review your API endpoints for these six anti-patterns. Start with the ones that have the highest impact: fix chatty endpoints with aggregation, add pagination to list endpoints, and eliminate synchronous blocking. Small changes here can dramatically improve performance without a full rewrite. The next time you plan a sprint, include a performance review of your API surface—your users will thank you.
Comments (0)
Please sign in to post a comment.
Don't have an account? Create one
No comments yet. Be the first to comment!