Introduction
You've seen it before: an export endpoint that loads 100,000 rows into memory, allocates a giant byte[], and takes down your server under concurrent load. But it doesn't have to be this way.
In this article, I'll show you how to build Excel export endpoints that stream data directly from your database to the HTTP response — without ever materializing the full dataset in memory.
The Problem with Traditional Approaches
Here's a typical export endpoint using a popular Excel library:
app.MapGet("/export/orders", async (AppDbContext db) =>
{
// Step 1: Load ALL data into memory
var orders = await db.Orders
.Where(o => o.Status == OrderStatus.Active)
.ToListAsync(); // 100,000 rows → ~50 MB in memory
// Step 2: Build in-memory workbook
using var wb = new XLWorkbook();
var ws = wb.Worksheets.Add("Orders");
ws.Cell(1, 1).InsertData(orders); // Another copy in the DOM
// Step 3: Serialize to byte[]
using var ms = new MemoryStream();
wb.SaveAs(ms);
byte[] result = ms.ToArray(); // Third copy: the final bytes
return Results.File(result, "application/vnd.openxmlformats...");
});
That's three copies of your data in memory simultaneously. Under 10 concurrent requests, you're looking at 500+ MB of memory pressure. The GC will not be happy.
What We Want Instead
The ideal flow: database cursor → stream → HTTP response wire. Data flows through, never accumulating.
┌──────────┐ ┌──────────────┐ ┌──────────────┐
│ Database │ ──→ │ Excel Writer │ ──→ │ HTTP Response │
│ (yield) │ │ (stream) │ │ (chunked) │
└──────────┘ └──────────────┘ └──────────────┘
↑ ↑ ↑
One row at Writes to Client receives
a time, async response body as it's generated
This requires two things:
An
IAsyncEnumerable<T>data source.An Excel writer that accepts
IAsyncEnumerable<T>and writes to aStream.
Step 1: The Data Source (IAsyncEnumerable)
EF Core makes this straightforward with AsAsyncEnumerable():
async IAsyncEnumerable<OrderExportDto> StreamOrdersAsync(AppDbContext db)
{
await foreach (var order in db.Orders
.AsNoTracking()
.Where(o => o.Status == OrderStatus.Active)
.Select(o => new OrderExportDto
{
OrderNo = o.OrderNo,
CustomerName = o.Customer.Name,
Amount = o.Amount,
CreatedAt = o.CreatedAt
})
.AsAsyncEnumerable())
{
yield return order;
}
}
EF Core's AsAsyncEnumerable() streams results using a database cursor — the driver fetches rows from the server in batches, and you process them one at a time. No ToList() means no full materialization.
Step 2: The Streaming Writer
The writer needs to accept an IAsyncEnumerable<T> and a Stream:
// The magic: write rows as they arrive from the async enumerator
public static async Task WriteAsync<T>(
Stream output,
IAsyncEnumerable<T> data,
Action<ExportProfile<T>>? configure = null,
CancellationToken cancellationToken = default)
Internally, it:
Opens a
ZipArchiveon the output stream.Writes static parts (
[Content_Types].xml,styles.xml, etc.).Opens the sheet XML entry.
Iterates the
IAsyncEnumerable<T>; for each row, writes the XML directly to the ZIP entry's stream.Closes the sheet entry, writes relationship files, and completes the ZIP.
The critical point: rows are written to the output stream as they arrive from the enumerator. If the output stream is the HTTP response body, the client starts downloading before the server has processed all rows.
Step 3: The Minimal API Endpoint
Putting it together:
app.MapGet("/export/orders", async (HttpContext ctx, AppDbContext db) =>
{
ctx.Response.ContentType =
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
ctx.Response.Headers["Content-Disposition"] =
"attachment; filename=orders.xlsx";
await Xlsx.WriteAsync(
ctx.Response.Body, // Output: HTTP response stream
StreamOrdersAsync(db), // Input: IAsyncEnumerable<T>
p => p
.Sheet("Active Orders")
.Column(x => x.OrderNo, c => c.WithName("Order #").WithWidth(20))
.Column(x => x.Amount, c => c.WithFormat("$#,##0.00"))
.WithFreezeHeader(),
ctx.RequestAborted); // Cancellation support
// No return — response has been written directly to the stream
});
Now stream 100,000 rows, and your memory usage stays flat at approximately 1 MB regardless of dataset size.
Cancellation Support
Notice the ctx.RequestAborted token. If the client disconnects (browser tab closed, network error), the token cancels the operation:
await Xlsx.WriteAsync(
ctx.Response.Body,
StreamOrdersAsync(db),
p => p.Sheet("Orders"),
ctx.RequestAborted); // Client disconnect → cancellation
The async enumerator respects the token, the ZipArchive closes cleanly (or at least doesn't leave a corrupted ZIP), and server resources are freed.
Async Reading: The Other Direction
Import works the same way in reverse:
app.MapPost("/import/orders", async (HttpRequest req, AppDbContext db) =>
{
await foreach (var order in Xlsx.ReadAsync<OrderImportDto>(req.Body))
{
db.Orders.Add(new Order
{
OrderNo = order.OrderNo,
Amount = order.Amount,
// ...
});
}
await db.SaveChangesAsync();
return Results.Ok();
});
ReadAsync<T> returns IAsyncEnumerable<T>, so you can await foreach and process rows one at a time — no ToList() between parsing and processing.
Memory Profile: Visual Comparison
Let's compare memory behavior under load (10 concurrent requests, 50,000 rows each):
| Approach | Peak Memory | Gen2 GC Events | P99 Latency |
|---|---|---|---|
| Traditional (ToList + DOM) | 480 MB | 12 | 8.3 s |
| Streaming (IAsyncEnumerable + WriteAsync) | 18 MB | 0 | 2.1 s |
The streaming approach simply doesn't allocate enough to trigger Gen2 collections. The P99 latency improves because there's no GC pause in the middle of request processing.
Benchmarks: 10,000 × 4 Columns (String)
Just to ground this in numbers, here's a single-request comparison on .NET 10 (Apple M4):
| Library | Write Time | Allocated |
|---|---|---|
| Streaming (IAsyncEnumerable path) | 3.1 ms | 0.98 MB |
| MiniExcel | 11.9 ms | 36.3 MB |
| EPPlus | 40.2 ms | 29.6 MB |
| ClosedXML | 50.4 ms | 84.4 MB |
The streaming writer is approximately 4× faster than MiniExcel and uses 37× less memory — and this is for a single request. Under concurrent load, the gap widens dramatically because the traditional libraries multiply their allocation by the number of concurrent requests.
When NOT to Use This Approach
Streaming has real tradeoffs. Choose carefully:
Post-Write Cell Modification
Once a row is written, you can't go back. You can't merge cells that span rows you haven't written yet (unless you pre-compute the range).
Formulas Referencing Forward Rows
SUM(A2:A10000) works because it references the range, not individual cells. But =A2+A3 works because both cells have been written when row 3 is processed.
Pivot Tables and Charts
These typically require the full dataset upfront. Generate them in a second pass or use a DOM-based library.
The Key Insight
IAsyncEnumerable<T> isn't just a syntactical convenience — combined with a streaming writer, it fundamentally changes the memory model of your export pipeline. Your memory usage becomes O(1) relative to dataset size, not O(n).
If you're generating Excel files in a web API, this pattern is worth adopting. The code in this article uses the Magicodes.IE.IO library (MIT), but the streaming principle applies regardless of which Excel library you choose.
Summary
Traditional Excel export implementations often load entire datasets into memory, resulting in high memory consumption, increased garbage collection activity, and poor scalability under concurrent load. By combining EF Core's IAsyncEnumerable<T> with a streaming Excel writer, developers can create export pipelines that process rows incrementally and write directly to the HTTP response stream. This approach keeps memory usage constant regardless of dataset size, improves response times, reduces GC pressure, and enables highly scalable Excel exports in ASP.NET Core applications.
0 Comments
Log in to join the conversation.No comments yet. Be the first to share your thoughts.