ASP.NET Core APIs often perform well when they return small and predictable response objects. Problems usually become visible when an endpoint starts returning a large amount of JSON.
A response containing a few hundred records may work without any noticeable issue. The same endpoint can behave very differently when it starts returning thousands of records, large nested objects, long strings, or multiple related collections.
The problem is not only the size of the final JSON sent over the network. Large responses can consume memory during query execution, object creation, JSON serialization, buffering, compression, and network transfer. If several users request the same large endpoint at the same time, those costs can multiply.
This means an API can become slow or run into memory pressure even when the database query itself looks acceptable.
What Happens When ASP.NET Core Returns JSON
Consider a simple controller action:
[HttpGet]
public async Task<ActionResult<List<Order>>> GetOrders()
{
var orders = await dbContext.Orders.ToListAsync();
return Ok(orders);
}
At first glance, this looks harmless.
But the application may perform several operations before the client receives the response:
Database
↓
Rows loaded into application memory
↓
.NET objects created
↓
Objects passed to JSON serializer
↓
JSON generated
↓
Response written to HTTP connection
↓
Client receives data
Each stage can consume resources.
If the query returns 10,000 large objects, the application is not simply sending 10,000 rows directly from the database to the client. It may first materialize those rows into objects and then serialize those objects into JSON.
That distinction becomes important when diagnosing performance.
Response Size Is Only Part of the Problem
Suppose an API returns:
{
"id": 1001,
"name": "Product",
"description": "...",
"price": 100
}
for 50 records.
The response may be small enough that the endpoint performs well.
Now imagine each object contains:
The final response can become several megabytes.
For example:
50 records → 200 KB
1,000 records → 4 MB
10,000 records → 40 MB
The exact numbers depend on the data, but the scaling problem is the important part.
A response that was designed when the database contained 500 records may become a production problem after the dataset grows to several hundred thousand records.
Large Responses Consume Server Memory
One of the most common misconceptions is that the server only needs enough memory for the final response.
In reality, the application may have to hold many objects and intermediate data while processing the request.
For example:
Database rows
↓
Entity objects
↓
DTO objects
↓
Serialized JSON
↓
Compressed response
Depending on the application and serialization pipeline, multiple representations of the data may exist during processing.
Suppose an endpoint returns 20 MB of JSON.
The application's memory usage can be considerably higher than 20 MB because the JSON is produced from objects that already occupy memory.
If ten users request the endpoint concurrently, the memory pressure can increase further.
This is why response-size problems often become much more serious under concurrent traffic.
The Database Query Can Be the First Bottleneck
Before optimizing JSON serialization, check what the database is returning.
This query:
var orders = await dbContext.Orders.ToListAsync();
loads every selected column for every matching row.
If the Order entity contains fields that the API does not need, the application is doing unnecessary work.
For example:
var orders = await dbContext.Orders
.Select(o => new OrderResponse
{
Id = o.Id,
Number = o.Number,
Total = o.Total
})
.ToListAsync();
Now the database only needs to return the fields required by the API response.
This can reduce:
Database transfer
Database memory usage
Application memory usage
Object creation
Serialization work
Response size
Projection is therefore useful before the data reaches the JSON serializer.
Returning Entities Can Make Responses Larger
Returning Entity Framework entities directly can also create problems.
For example:
return Ok(await dbContext.Orders.ToListAsync());
The entity may contain properties that should not be exposed by the API.
It may also contain relationships that make the resulting object graph much larger than expected.
A DTO provides a deliberate response shape:
public sealed class OrderResponse
{
public int Id { get; set; }
public string Number { get; set; } = string.Empty;
public decimal Total { get; set; }
}
Then:
var orders = await dbContext.Orders
.Select(o => new OrderResponse
{
Id = o.Id,
Number = o.Number,
Total = o.Total
})
.ToListAsync();
This makes the API contract explicit.
It also prevents unrelated fields from becoming part of the response simply because they exist on the entity.
Pagination Is Usually the First Real Fix
If an endpoint can return thousands of records, pagination is usually a better solution than trying to serialize everything efficiently.
A simple request might look like:
GET /api/orders?page=1&pageSize=50
The server can then return only the requested records.
For example:
var orders = await dbContext.Orders
.OrderBy(o => o.Id)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.Select(o => new OrderResponse
{
Id = o.Id,
Number = o.Number,
Total = o.Total
})
.ToListAsync();
The important part is that pagination should happen at the database level.
Avoid loading all records first and then taking a subset in application memory.
This is inefficient:
var allOrders = await dbContext.Orders.ToListAsync();
var page = allOrders
.Skip(...)
.Take(...)
.ToList();
The database still returned everything.
The better approach lets the database perform the filtering and limiting.
Offset Pagination Has Limits
Skip and Take are easy to understand, but offset pagination can become less efficient for very large datasets.
Consider:
Page 1 → Skip 0
Page 2 → Skip 50
Page 1000 → Skip 49,950
The database may need to process increasingly large portions of the result set before returning the requested page, depending on the query and database engine.
For large or frequently changing datasets, keyset or cursor-based pagination can be more efficient.
For example:
GET /api/orders?afterId=1050&limit=50
The query can then use the last known ID as a starting point:
var orders = await dbContext.Orders
.Where(o => o.Id > afterId)
.OrderBy(o => o.Id)
.Take(50)
.Select(o => new OrderResponse
{
Id = o.Id,
Number = o.Number,
Total = o.Total
})
.ToListAsync();
This approach can work particularly well when the data has a suitable indexed ordering column.
Avoid Large Nested Object Graphs
A response can become large even when the top-level record count is small.
For example:
Customer
↓
Orders
↓
OrderItems
↓
Product
↓
ProductReviews
Returning the entire graph can create a very large JSON document.
The API should decide which information the client actually needs.
Instead of returning:
{
"id": 1,
"name": "Customer",
"orders": [
{
"items": [
{
"product": {
"reviews": []
}
}
]
}
]
}
a summary endpoint might return:
{
"id": 1,
"name": "Customer",
"orderCount": 15
}
Then a separate endpoint can retrieve detailed order information.
This keeps response contracts focused and reduces unnecessary data transfer.
JSON Serialization Also Takes CPU
Even when database access is fast, serialization itself requires CPU.
The application has to convert objects into JSON text.
For example:
Object graph
↓
Property inspection
↓
JSON encoding
↓
UTF-8 output
↓
HTTP response
Large object graphs require more serialization work.
If an endpoint returns a small DTO, serialization may be insignificant.
If it returns tens of thousands of objects with nested collections, serialization can become a meaningful part of request latency.
This is one reason response profiling should look at the entire request rather than assuming the database is responsible for all slowness.
Compression Reduces Network Transfer but Costs CPU
ASP.NET Core can use response compression to reduce the amount of data transferred over the network.
For example:
Uncompressed JSON: 10 MB
Compressed response: 1.5 MB
The exact compression ratio depends heavily on the data.
Compression can improve network performance, especially for large text-based JSON responses.
However, compression also consumes CPU.
If the application is already CPU constrained, aggressively compressing very large responses can create another bottleneck.
Compression should therefore be evaluated together with:
Response size
Network bandwidth
CPU usage
Request concurrency
Client capabilities
Compression is useful, but it is not a replacement for returning less data.
Streaming Can Help With Large Results
Some APIs genuinely need to process large amounts of data.
In such cases, streaming can prevent the application from having to materialize the entire result before sending it.
The exact implementation depends on the API and serialization requirements.
The basic idea is:
Traditional approach
Database
↓
Load everything
↓
Serialize everything
↓
Send response
Streaming approach
Database
↓
Read a portion
↓
Serialize
↓
Send
↓
Read next portion
↓
Serialize
↓
Send
Streaming can reduce peak memory usage because the application does not necessarily need to hold the entire result at once.
However, streaming introduces additional complexity around connection lifetime, cancellation, errors after partial responses, and client behavior.
It should be used when the workload actually requires large result sets rather than as the default solution for every endpoint.
Cancellation Matters for Large Responses
Suppose a client requests a large export and then closes the browser.
If the server continues processing the entire operation, it may waste CPU, memory, database connections, and network resources.
ASP.NET Core supports request cancellation through the request cancellation token.
For example:
[HttpGet]
public async Task<IActionResult> GetOrders(
CancellationToken cancellationToken)
{
var orders = await dbContext.Orders
.AsNoTracking()
.Select(o => new OrderResponse
{
Id = o.Id,
Number = o.Number,
Total = o.Total
})
.ToListAsync(cancellationToken);
return Ok(orders);
}
If the request is cancelled, the database operation can also be cancelled where the provider supports it.
This is particularly useful for expensive endpoints.
Large JSON Responses Can Affect Clients Too
The server is not the only system affected.
A large JSON response can cause problems for clients such as:
Browser applications
Mobile applications
Desktop applications
Other backend services
The client must download, parse, and often hold the JSON in memory.
A 20 MB response may be manageable on a server but expensive for a mobile application running on a constrained device or network.
Large responses also increase latency for users on slower connections.
This is why API response design should consider the entire request path:
Database
↓
API server
↓
Network
↓
Client
↓
Client memory
↓
Client processing
Reducing unnecessary data helps every part of that path.
Use Different Endpoints for Summary and Detail Data
A useful API design is to separate lightweight list responses from detailed resource responses.
For example:
GET /api/orders
could return:
[
{
"id": 1001,
"number": "ORD-1001",
"total": 250
}
]
while:
GET /api/orders/1001
could return detailed information.
This prevents list pages from downloading information they do not need.
It also gives clients more control over when expensive data is requested.
Measure Response Size in Production
A response-size problem is difficult to manage if it is not measured.
Useful metrics include:
Response size
Request duration
Serialization time
Database duration
Memory usage
CPU usage
Requests per second
Status code distribution
Large-response frequency
For example, an endpoint might show:
Average response: 200 KB
P95 response: 1.8 MB
P99 response: 14 MB
P95 latency: 220 ms
P99 latency: 4.2 sec
The average response looks healthy.
The P99 response tells a different story.
This is why averages alone are often insufficient when investigating API performance.
Common Mistakes
Returning Every Record
An endpoint that returns all records can become slower as the database grows.
Use pagination or another bounded retrieval strategy.
Loading Everything Before Filtering
Do not load thousands of database records into memory just to filter them afterward.
Push filtering, sorting, projection, and pagination into the database query.
Returning Full Entities
Entities often contain more data and relationships than the API needs.
Use focused DTOs.
Assuming Compression Fixes Large Responses
Compression reduces network transfer but does not eliminate database, serialization, or memory costs.
Ignoring Nested Collections
A response with 100 customers can still become huge if each customer contains hundreds of orders and related objects.
Optimizing Only the Database
A fast query can still produce a slow API if serialization and network transfer dominate the request.
Ignoring Client Cancellation
Continuing expensive work after the client disconnects wastes resources.
Use cancellation tokens for long-running database and application operations.
Best Practices
Keep API responses intentionally small.
Use DTOs instead of exposing large entity graphs.
Apply filtering and projection at the database level.
Use pagination for potentially large collections.
Consider keyset pagination for large datasets.
Use compression when it provides a meaningful network benefit.
Use streaming for workloads that genuinely require large results.
Pass cancellation tokens through expensive operations.
Separate summary endpoints from detailed resource endpoints.
Measure response sizes and latency percentiles in production.
Set reasonable limits on collection sizes and request parameters.
A Practical Checklist
When an ASP.NET Core endpoint starts returning large JSON responses, check the problem in this order:
Measure the actual response size.
Check how many database rows are being returned.
Check whether unnecessary columns are being selected.
Check for nested collections or large object graphs.
Add projection with DTOs.
Add database-level pagination.
Check serialization time and CPU usage.
Check application memory during concurrent requests.
Evaluate response compression.
Consider streaming if the business requirement genuinely needs large results.
Support request cancellation.
Monitor large responses in production.
This approach helps identify whether the problem comes from data volume, database access, object creation, serialization, network transfer, or concurrency.
Summary
Large JSON responses can affect much more than network speed. They can increase database work, application memory usage, CPU consumption, serialization time, and client-side processing.
The best solution is usually to return less data: use DTOs, projection, pagination, focused endpoints, and appropriate filtering. For workloads that genuinely require large results, streaming and compression can help.
A fast API is not just one that processes requests quickly. It should also avoid moving more data than the client and server actually need.
Join the conversation! Your thoughts help the community grow.