Returning JSON from an ASP.NET Core API is straightforward when the response contains a few records. The situation changes when an endpoint needs to return hundreds of thousands of records, large reports, or exports containing millions of database rows.
A common implementation loads every record into a list, converts the list into JSON, and returns the result. This approach is easy to write, but it can consume a large amount of memory. If several clients request large exports at the same time, the application may experience high memory usage, longer garbage collection pauses, and slow responses.
One way to reduce this problem is to stream the response instead of building the complete result in memory before sending it. However, streaming must be designed carefully. Returning IAsyncEnumerable<T> does not automatically guarantee that the database query, serializer, and HTTP response will all operate with minimal memory usage.
In this article, we will examine the common causes of memory problems, implement streaming JSON in ASP.NET Core, and discuss database queries, cancellation, error handling, and production considerations.
Why Large JSON Responses Consume Too Much Memory
Consider an endpoint that returns a large collection of orders.
[HttpGet("orders")]
public async Task<IActionResult> GetOrders(
CancellationToken cancellationToken)
{
var orders = await _dbContext.Orders
.ToListAsync(cancellationToken);
return Ok(orders);
}
The ToListAsync() call reads all matching rows into a collection before the endpoint returns the response.
The application must then serialize those objects into JSON. Depending on the serializer, response pipeline, and server behavior, additional buffers may also be required.
As a result, the memory used during a request can be considerably larger than the database result's raw size.
The problem becomes more serious when multiple requests run concurrently. Each request may hold its own collection and related serialization data.
For large exports, it is often better to read and write data incrementally rather than materializing everything at once.
What Is JSON Streaming?
JSON streaming means producing a response progressively instead of constructing the entire response in memory first.
For example, a conventional JSON array might look like this:
[
{
"id": 1,
"status": "Completed"
},
{
"id": 2,
"status": "Pending"
}
]
A streaming implementation can write the opening array, serialize each item as it becomes available, and then write the closing array.
The client can begin receiving the response before the complete dataset has been processed, provided that the server, network, and client support progressive delivery.
Streaming does not reduce the total amount of data transferred. Its primary benefit is reducing the need to keep the entire response in application memory at once.
Step 1: Return an Asynchronous Sequence from ASP.NET Core
ASP.NET Core supports returning asynchronous sequences from endpoints. When configured appropriately, the framework can serialize the sequence incrementally.
Consider this example:
[HttpGet("orders/stream")]
public IAsyncEnumerable<OrderResponse> StreamOrders(
CancellationToken cancellationToken)
{
return _dbContext.Orders
.AsNoTracking()
.OrderBy(order => order.Id)
.Select(order => new OrderResponse(
order.Id,
order.Status,
order.Total))
.AsAsyncEnumerable();
}
public record OrderResponse(
int Id,
string Status,
decimal Total);
This example uses Entity Framework Core to return an asynchronous sequence of projected order records.
The important difference is that it does not call ToListAsync(). Instead, the query exposes an asynchronous stream of results.
AsNoTracking() is appropriate for this read-only operation because the returned entities do not need to be tracked for updates. The projection selects only the fields required by the API, reducing the amount of data read and serialized.
The ordering also makes the output order predictable.
However, this code does not guarantee that every provider will fetch rows one at a time. The database provider may buffer internally, and framework serialization behavior depends on the ASP.NET Core version and configuration. Test the complete pipeline with your actual database provider.
Step 2: Understand the Role of IAsyncEnumerable
IAsyncEnumerable<T> allows an application to retrieve items asynchronously as they become available.
For example, a method can yield one item at a time:
public async IAsyncEnumerable<int> GetNumbersAsync(
[System.Runtime.CompilerServices.EnumeratorCancellation]
CancellationToken cancellationToken = default)
{
for (int i = 1; i <= 10; i++)
{
cancellationToken.ThrowIfCancellationRequested();
await Task.Delay(10, cancellationToken);
yield return i;
}
}
The method does not create a list containing all the values before returning. Each value becomes available as the caller advances the asynchronous enumerator.
In a web API, this pattern can help avoid unnecessary collection allocation.
There is an important distinction, though: an asynchronous sequence is a way to produce data incrementally, not a guarantee about the memory behavior of every component that consumes it.
For example, if the sequence is converted to a list before serialization, the memory benefit disappears. If the database provider buffers the complete result internally, application-level streaming may not provide the expected savings.
Step 3: Pass Cancellation Through the Entire Operation
Large exports can take time. A client may disconnect before the export finishes, or the request may be cancelled because of a timeout.
The application should stop doing unnecessary work when cancellation is detected.
For controller actions, ASP.NET Core can bind a CancellationToken parameter to the request-aborted token. Pass that token to database operations and other asynchronous work.
For a custom asynchronous iterator, use the EnumeratorCancellation attribute to support cancellation during enumeration.
using System.Runtime.CompilerServices;
public async IAsyncEnumerable<OrderResponse> StreamOrdersAsync(
[EnumeratorCancellation]
CancellationToken cancellationToken = default)
{
await foreach (var order in _dbContext.Orders
.AsNoTracking()
.OrderBy(order => order.Id)
.Select(order => new OrderResponse(
order.Id,
order.Status,
order.Total))
.AsAsyncEnumerable()
.WithCancellation(cancellationToken))
{
yield return order;
}
}
This example passes the cancellation token into asynchronous enumeration. The database provider must support cancellation for it to stop underlying work promptly.
Cancellation is especially important for large exports because continuing to query and serialize data after a client disconnects wastes database and server resources.
Do not catch cancellation exceptions and continue producing the response as though nothing happened.
Step 4: Use Manual JSON Streaming When You Need More Control
Returning an asynchronous sequence is convenient, but sometimes an application needs control over exactly when JSON is written.
For example, an export endpoint may need to write a JSON array directly to the response body.
The following example demonstrates the basic pattern:
using System.Text.Json;
[HttpGet("orders/export")]
public async Task ExportOrders(
CancellationToken cancellationToken)
{
Response.ContentType = "application/json";
await using var writer = new Utf8JsonWriter(
Response.BodyWriter);
writer.WriteStartArray();
await writer.FlushAsync(cancellationToken);
await foreach (var order in _dbContext.Orders
.AsNoTracking()
.OrderBy(order => order.Id)
.Select(order => new OrderResponse(
order.Id,
order.Status,
order.Total))
.AsAsyncEnumerable()
.WithCancellation(cancellationToken))
{
JsonSerializer.Serialize(writer, order);
await writer.FlushAsync(cancellationToken);
}
writer.WriteEndArray();
await writer.FlushAsync(cancellationToken);
}
The writer sends JSON directly to the response body rather than constructing a complete serialized string first.
The opening array is written before the records are processed. Each order is serialized and flushed, and the closing array is written after enumeration completes.
Flushing after every record provides a simple illustration, but it can increase overhead significantly for large exports. In production, flush in sensible batches or rely on appropriate buffering behavior, then measure the effect.
This example also assumes the response body has not already been started by incompatible middleware and that no other component is writing to the same response concurrently.
A limitation of streaming JSON
Once the server has sent part of a JSON response, it generally cannot replace that response with a different HTTP status code if an error occurs later.
For example, if the database connection fails after 50,000 records have been written, the client may receive an incomplete JSON array.
The application cannot simply return a normal 500 Internal Server Error response in place of the bytes already sent.
For this reason, validate authorization, request parameters, and other predictable failure conditions before starting the response. If clients require reliable completion detection, consider a streaming format with explicit record and completion events, or use a background export process that creates a complete file before making it available.
Step 5: Consider Pagination Instead of One Huge Response
Streaming is useful when clients genuinely need a large dataset in one response. It is not always the best solution for normal API queries.
For a user interface that displays orders in a table, pagination is often simpler.
[HttpGet("orders")]
public async Task<IActionResult> GetOrders(
int page = 1,
int pageSize = 100,
CancellationToken cancellationToken = default)
{
if (page < 1 || pageSize < 1 || pageSize > 500)
{
return BadRequest("Invalid pagination values.");
}
var orders = await _dbContext.Orders
.AsNoTracking()
.OrderBy(order => order.Id)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.Select(order => new OrderResponse(
order.Id,
order.Status,
order.Total))
.ToListAsync(cancellationToken);
return Ok(orders);
}
This example limits the number of returned records and prevents clients from requesting an arbitrarily large page.
Offset pagination can become expensive for large page numbers, and records may shift between requests when data changes. For large or frequently changing datasets, keyset pagination based on an indexed, stable ordering key is often a better option.
For example, a client can request the next page using the last order ID it received, rather than asking the database to skip a large number of rows.
Streaming vs. Pagination
Consideration | Streaming | Pagination |
|---|
Response delivery | Progressive delivery may be possible | Each page is a separate response |
Memory use | Can reduce memory when every layer streams | Bounded by the page size |
Client complexity | Client must handle a long-running response | Client requests pages as needed |
Error handling | Late failures can leave incomplete output | Each page can succeed or fail independently |
Best suited for | Exports and large sequential datasets | Interactive lists and search results |
Restarting after failure | Usually requires custom resume support | Clients can retry a specific page or cursor |
Choose based on what the client needs. Streaming is useful for bulk transfer, while pagination is usually a better fit for interactive screens.
Common Mistakes to Avoid
Calling ToListAsync before returning the response
This loads the full result into memory and removes the primary benefit of streaming.
Assuming IAsyncEnumerable guarantees low memory usage
The database provider, serializer, middleware, and client all affect the complete memory profile. Test the actual application rather than relying only on the return type.
Returning every column from the database
Large text fields, binary data, and unused properties can increase database traffic and response size. Project only the fields the client needs.
Ignoring database query performance
Streaming does not make an inefficient query efficient. Add appropriate indexes, inspect query plans, and use a stable ordering strategy.
Flushing after every small write
Frequent flushing can increase overhead and reduce throughput. Choose a suitable buffering strategy based on the response size and client requirements.
Ignoring partial responses
A failure after the response starts may leave the client with incomplete JSON. Consider how the client will detect failure and whether the export should be generated before download.
Best Practices for Production
Use streaming for large sequential responses that clients genuinely need to consume in one operation.
Use pagination for normal list and search endpoints.
Project only the required fields instead of serializing complete database entities.
Use AsNoTracking() for read-only EF Core queries when entity tracking is unnecessary.
Pass cancellation tokens through database access, enumeration, and serialization operations.
Set sensible request limits, query limits, and timeouts.
Test whether the database provider buffers results and measure memory usage under concurrent requests.
Use stable ordering and consider keyset pagination for large datasets.
Plan for failures that occur after response headers or body content have been sent.
Measure database execution time, response size, memory use, and throughput before and after changing the implementation.
Advantages and Disadvantages of JSON Streaming
Advantages
Avoids building the entire JSON response in application memory when the pipeline streams effectively.
Can allow clients to begin receiving data earlier.
Can reduce memory pressure for large sequential exports.
Works with asynchronous database access and cancellation.
Disadvantages
Requires more careful handling of late failures.
May hold database resources open for longer.
Performance depends on the database provider, serializer, and network behavior.
Clients may need special handling for partial or interrupted responses.
Does not reduce the total amount of data that must be transferred.
Summary
Large JSON responses can put unnecessary pressure on an ASP.NET Core application when the endpoint loads every record into memory before sending the response. Asynchronous enumeration and JSON streaming can reduce this pressure when the database provider and response pipeline support incremental processing.
Use IAsyncEnumerable<T> for a straightforward streaming endpoint, or write directly to the response body when more control is needed. Pass cancellation tokens through the entire operation, project only the required fields, and account for failures after the response has started.
For interactive screens, pagination is often simpler and more reliable. The right approach depends on whether the client needs a large sequential export or a manageable set of records at a time.
Jasen FiciPosted Oct 21, 2026, 12:53 PM
Thanks for publishing this. It made it into DotNetNews: https://dotnetnews.beehiiv.com/p/new-post-5ab1