When an ASP.NET Core API returns a small amount of data, loading the result into memory is usually not a problem. But the situation changes when an endpoint needs to return hundreds of thousands of database records or a large collection of files.
For example, imagine an API that exports customer records for a reporting system. If the application loads every record into a list and then converts the entire list into JSON, memory usage can grow quickly. Multiple requests running at the same time can make the problem worse.
A better approach is to process the data in smaller parts and write the response as it becomes available.
ASP.NET Core supports streaming responses, and System.Text.Json provides APIs for writing JSON directly to a stream. When used correctly, these features can reduce memory pressure and help an API handle large responses more efficiently.
In this article, we will explore common memory problems, implement JSON streaming in ASP.NET Core, and look at the trade-offs you should understand before using it in production.
Why Large JSON Responses Consume Too Much Memory
Consider a simple API endpoint:
[HttpGet("customers")]
public async Task<IActionResult> GetCustomers()
{
var customers = await _context.Customers
.AsNoTracking()
.ToListAsync();
return Ok(customers);
}This code loads all matching customers into memory before returning the response. ASP.NET Core then serializes the collection into JSON.
For a small table, this approach is easy to understand and maintain. For a large dataset, however, the application may need memory for the collection, the objects it contains, and the serialized response data.
The exact memory usage depends on the data size, object structure, serializer, and runtime behavior.
The main problem is that the application attempts to prepare the entire result before sending it.
Streaming changes this approach by allowing the application to read and write data progressively rather than building one complete response in memory.
What Is JSON Streaming?
JSON streaming means writing JSON output to a response stream as data becomes available.
Instead of building a complete JSON string or collection, the application writes each record as part of the response.
For example, a normal JSON response might look like this:
[
{
"id": 1,
"name": "Customer One"
},
{
"id": 2,
"name": "Customer Two"
}
]The final response still contains a valid JSON array. The difference is that the server can produce it incrementally.
Streaming does not automatically make every part of the application memory-efficient. The database query must also avoid loading the entire result, and the code must not accumulate all serialized data in another buffer.
Step 1: Stream JSON from an ASP.NET Core Endpoint
ASP.NET Core supports asynchronous response writing through HttpResponse.Body.
The following example uses Utf8JsonWriter to write a JSON array one record at a time.
using System.Text.Json;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
[ApiController]
[Route("api/customers")]
public class CustomersController : ControllerBase
{
private readonly AppDbContext _context;
public CustomersController(AppDbContext context)
{
_context = context;
}
[HttpGet("export")]
public async Task ExportCustomers(
CancellationToken cancellationToken)
{
Response.ContentType = "application/json";
await using var writer = new Utf8JsonWriter(
Response.Body,
new JsonWriterOptions
{
Indented = false
});
writer.WriteStartArray();
await foreach (var customer in _context.Customers
.AsNoTracking()
.OrderBy(c => c.Id)
.Select(c => new
{
c.Id,
c.Name,
c.Email
})
.AsAsyncEnumerable()
.WithCancellation(cancellationToken))
{
JsonSerializer.Serialize(
writer,
customer);
await writer.FlushAsync(cancellationToken);
}
writer.WriteEndArray();
await writer.FlushAsync(cancellationToken);
}
}This endpoint writes the opening bracket, serializes each customer, and finally writes the closing bracket.
The important parts are:
AsNoTracking()avoids tracking entities that are only being read.Select()retrieves only the fields required by the response.AsAsyncEnumerable()allows asynchronous iteration over the query results.Utf8JsonWriterwrites JSON directly to the response stream.FlushAsync()sends buffered output onward without waiting for the entire array to be created.WithCancellation()passes request cancellation into the asynchronous enumeration.
The example assumes that Id, Name, and Email are properties of the customer entity and that the database provider supports asynchronous query execution.
A few details matter in production. First, flushing after every record can create unnecessary overhead. For high-volume endpoints, consider flushing after a batch of records instead. Second, once response bytes have been sent, the server cannot reliably replace the response with a different JSON error object if a later operation fails.
Streaming also does not guarantee that the database provider will never buffer internally. Test the actual query and provider to understand their behavior.
Step 2: Use IAsyncEnumerable for Simpler Streaming
For many APIs, you do not need to write JSON manually.
ASP.NET Core can serialize an IAsyncEnumerable<T> returned from an action. This allows the application to produce records asynchronously while the framework handles JSON serialization.
[HttpGet("stream")]
public IAsyncEnumerable<CustomerDto> GetCustomers(
CancellationToken cancellationToken)
{
return _context.Customers
.AsNoTracking()
.OrderBy(c => c.Id)
.Select(c => new CustomerDto
{
Id = c.Id,
Name = c.Name,
Email = c.Email
})
.AsAsyncEnumerable()
.WithCancellation(cancellationToken);
}
public sealed class CustomerDto
{
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
public string Email { get; set; } = string.Empty;
}This is often easier to maintain than manually writing JSON.
With supported ASP.NET Core and System.Text.Json configurations, asynchronous enumeration can allow the response to be serialized progressively. The exact buffering behavior depends on the framework version, formatter, and response configuration.
The database query must also remain suitable for streaming. Avoid calling ToListAsync() before returning the data, because that loads all matching rows into memory.
Step 3: Use Pagination When Clients Need Smaller Results
Streaming is not always the best answer. If a client only needs to display a table of customers, returning every customer may waste network bandwidth and database resources.
Pagination allows the client to request a smaller set of records.
[HttpGet]
public async Task<IActionResult> GetCustomers(
int page = 1,
int pageSize = 100)
{
page = Math.Max(page, 1);
pageSize = Math.Clamp(pageSize, 1, 500);
var customers = await _context.Customers
.AsNoTracking()
.OrderBy(c => c.Id)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.Select(c => new CustomerDto
{
Id = c.Id,
Name = c.Name,
Email = c.Email
})
.ToListAsync();
return Ok(customers);
}This endpoint limits each response to at most 500 records.
Pagination is useful when users browse data or when clients can process results in multiple requests. For very large datasets, keyset pagination using a stable cursor is often more efficient than increasingly large Skip() values.
Also consider validating maximum page sizes and applying authorization and filtering before retrieving the data.
Streaming vs. Pagination
| Approach | Main benefit | Best use case | Main limitation |
|---|---|---|---|
Load everything with ToListAsync() |
Simple implementation | Small result sets | Memory grows with result size |
| JSON streaming | Sends results progressively | Large exports and sequential processing | More complex error and cancellation handling |
IAsyncEnumerable<T> |
Cleaner asynchronous enumeration | APIs that can serialize results incrementally | Actual behavior depends on framework and provider |
| Pagination | Limits the size of each response | Tables, search results, and interactive clients | Clients need multiple requests for all records |
Choose the approach based on what the client needs. Streaming is helpful when a client must receive a large result in one response. Pagination is often better for normal application screens.
Common Mistakes to Avoid
Calling ToListAsync Before Streaming
If you load the complete result into a list before writing the response, you lose much of the memory benefit.
Use asynchronous enumeration when supported by the query provider.
Flushing After Every Record
Frequent flushing may increase overhead and reduce throughput. Consider writing several records before flushing, then measure the behavior under realistic workloads.
Ignoring Client Disconnections
A client may close its connection while the export is running. Pass the request cancellation token to database operations, enumeration, and response-writing operations where supported.
Returning Too Many Database Columns
Retrieving complete entities when the client needs only three fields wastes work and can increase memory usage. Project the required fields with Select().
Forgetting That a Streamed Response Can Fail Halfway Through
If a database exception occurs after some JSON has been sent, the client may receive an incomplete JSON document. Log the failure and ensure the client can detect unsuccessful or incomplete downloads.
For critical exports, consider generating a file in the background and exposing it only after the file has been completed successfully.
Best Practices for Production Applications
- Use streaming for large exports when the client needs one continuous response.
- Prefer
IAsyncEnumerable<T>when framework-managed JSON serialization meets your needs. - Avoid unnecessary materialization with
ToListAsync()for large datasets. - Project only the fields required by the response.
- Pass cancellation tokens through database queries and response-writing operations.
- Apply request limits, authorization, and query filters before streaming data.
- Consider pagination or background export jobs for very large datasets.
- Monitor memory usage, database connections, response duration, and client disconnects.
- Test failure behavior after response headers and body bytes have already been sent.
- Avoid assuming that streaming eliminates every buffer in the database provider, serializer, web server, or network stack.
Advantages and Disadvantages of JSON Streaming
Advantages
- Avoids building the complete response in application memory.
- Can begin sending data before the query has finished producing all results.
- Supports large exports and sequential data processing.
- Works with asynchronous database access and cancellation.
Disadvantages
- Requires careful handling of partial responses and failures.
- Long-running responses may keep database resources occupied.
- Frequent flushing can reduce throughput.
- Clients must be able to process the response format and handle interrupted downloads.
Summary
Returning large JSON responses in ASP.NET Core does not require loading every record into memory first. By using asynchronous enumeration, Utf8JsonWriter, or IAsyncEnumerable<T>, an application can produce response data progressively.
However, streaming is only part of the solution. The database query, serializer, and response pipeline must all be considered, and cancellation and partial failures need proper handling.
For interactive screens, pagination is often the better choice. For large exports or clients that need a continuous result, streaming can be a practical way to reduce memory pressure while keeping the API responsive.


Join the conversation! Your thoughts help the community grow.