Using IAsyncEnumerable to Stream Excel Exports in ASP.NET Core
An export endpoint that loads 100,000 rows into memory, allocates a massive byte [], and brings down your server under concurrent demand is something you’ve seen before. However, things don’t have to be this way.
In this post, I’ll demonstrate how to create Excel export endpoints that, without ever creating the entire dataset in memory, stream data straight from your database to the HTTP response.
The Problem with Traditional Approaches
Here’s a typical export endpoint using a popular Excel library:
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.
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():
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:
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:
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:
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:
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.
ASP.NET Core 10.0 Hosting Recommendation
HostForLIFE.eu
HostForLIFE.eu is a popular recommendation that offers various hosting choices. Starting from shared hosting to dedicated servers, you will find options fit for beginners and popular websites. It offers various hosting choices if you want to scale up. Also, you get flexible billing plans where you can choose to purchase a subscription even for one or six months.
