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.
Join the conversation! Your thoughts help the community grow.