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

Join the conversation! Your thoughts help the community grow.