# How to Stream Large CSV Exports in ASP.NET Core: A Real-World Walkthrough

Every internal API eventually grows an export endpoint, and it is almost always the same story. Somebody in finance asks for "the transactions as a spreadsheet", a developer adds `GET /transactions/export`, it works beautifully against the 3,000 rows in staging, and eleven months later it takes down a pod because a user asked for a full year and the query returned 1.4 million rows. If you need to stream a large CSV export from an ASP.NET Core API without the memory profile going vertical, the fix is smaller than you would expect - but the production traps around it are the part nobody documents.

I have shipped this pattern more times than I can count, and the version below is the one that survived contact with real users. The complete implementation, including the background-job variant for genuinely huge exports and a load test that proves the memory ceiling holds, is on [Patreon](https://www.patreon.com/CodingDroplets) with the whole project wired together.

The part that makes or breaks a streaming export is not the CSV writer at all - it is the read path. Every row has to come off the database without EF Core tracking it, and the cancellation token has to reach all the way down. Chapter 3 of the [Zero to Production course](https://aspnetcoreapi.codingdroplets.com/) covers exactly that groundwork - `AsNoTracking` on read queries, threading `CancellationToken` through the repository, and the over-fetching pitfalls that quietly multiply your row cost - inside a complete API you can run.

[![ASP.NET Core Web API: Zero to Production](https://newsletter.codingdroplets.com/images/aspnet-core-api-course-banner-1.jpg align="center")](https://aspnetcoreapi.codingdroplets.com/)

## The Business Problem: An Export Endpoint That Falls Over at Scale

The naive implementation looks harmless:

```csharp
var rows = await db.Transactions
    .Where(t => t.Date >= from && t.Date <= to)
    .ToListAsync(ct);

return Results.File(BuildCsvBytes(rows), "text/csv", "transactions.csv");
```

Three separate copies of the data exist at peak: the EF Core materialised entities, the intermediate string or `StringBuilder`, and the final `byte[]`. On a 1.4 million row export with a dozen columns, that is comfortably several gigabytes of managed heap, most of it landing on the Large Object Heap. The container hits its memory limit and the orchestrator kills it, taking every other in-flight request with it.

The goal is different: **constant memory regardless of row count**. One row in flight, one row written, next row. Memory should look the same at 3,000 rows and at 3 million.

## Design Decision 1: Stream the Response Instead of Building It

ASP.NET Core gives you a purpose-built way to write directly to the response body. [`Results.Stream`](https://learn.microsoft.com/en-us/dotnet/api/microsoft.aspnetcore.http.results.stream) takes a callback that receives the raw response `Stream`, so nothing is buffered on your behalf:

```csharp
// The overload signature: Func<Stream, Task>, contentType, fileDownloadName
public static IResult Stream(
    Func<Stream, Task> streamWriterCallback,
    string? contentType = default,
    string? fileDownloadName = default, ...)
```

The `fileDownloadName` argument sets the `Content-Disposition` header for you, which is the small detail people usually hand-roll incorrectly. This overload has been available since .NET 7 and is unchanged in .NET 10.

There is a deliberate consequence here: the response has no `Content-Length`, because you do not know the size until you are done. Kestrel switches to chunked transfer encoding, and the browser shows a download with an unknown total. For an export that is a fair trade.

## Design Decision 2: Keep the Database Read Streaming Too

Streaming the response is pointless if you materialise the whole result set first. `ToListAsync` is the enemy; `AsAsyncEnumerable` is the tool.

```csharp
static IAsyncEnumerable<TransactionRow> Query(AppDbContext db, DateOnly from, DateOnly to) =>
    db.Transactions
        .AsNoTracking()                       // no change tracker growth
        .Where(t => t.Date >= from && t.Date <= to)
        .OrderBy(t => t.Id)                   // stable order for a stable file
        .Select(t => new TransactionRow(t.Id, t.Date, t.Amount, t.Reference))
        .AsAsyncEnumerable();
```

Two things are load-bearing. `AsNoTracking()` matters more here than on a normal read: without it, the change tracker accumulates an entry for every row you have ever streamed, and your "constant memory" export leaks linearly anyway. And the projection into a flat record means EF Core selects four columns rather than hydrating full entities with navigation properties you will never write to the file.

If the `IQueryable` versus `IAsyncEnumerable` boundary is fuzzy for your team, our breakdown of [IEnumerable vs IQueryable vs IAsyncEnumerable in .NET](https://codingdroplets.com/ienumerable-vs-iqueryable-vs-iasyncenumerable-dotnet) is worth ten minutes before you refactor anything.

## The Implementation: A Streaming CSV Endpoint

CsvHelper has accepted an `IAsyncEnumerable<T>` source since v27, which means the whole endpoint collapses to something you can read in one sitting:

```csharp
app.MapGet("/transactions/export", (DateOnly from, DateOnly to,
    AppDbContext db, CancellationToken ct) =>
{
    return Results.Stream(async stream =>
    {
        await using var writer = new StreamWriter(stream);
        await using var csv = new CsvWriter(writer, CultureInfo.InvariantCulture);
        await csv.WriteRecordsAsync(Query(db, from, to), ct);
    },
    contentType: "text/csv",
    fileDownloadName: $"transactions-{from:yyyyMMdd}-{to:yyyyMMdd}.csv");
});
```

That is the entire happy path. Memory stays flat, the first bytes reach the client in milliseconds instead of minutes, and the file downloads while the query is still running.

Note the `ct` handed to `WriteRecordsAsync`. That is not decoration - it is the difference between a cancelled download stopping the database query and a cancelled download leaving a reader open for another twenty minutes.

## What Breaks in Production (And Nobody Warns You)

The happy path is easy. These five are the ones that actually cost us time.

### Kestrel Kills Slow Downloads by Default

Kestrel enforces a minimum response data rate, and [`MinResponseDataRate`](https://learn.microsoft.com/en-us/dotnet/api/microsoft.aspnetcore.server.kestrel.core.kestrelserverlimits.minresponsedatarate) defaults to **240 bytes per second with a 5 second grace period**. Crucially, the docs are explicit that this rate is enforced *per write operation*, not averaged over the response: whenever the server writes a chunk, a timer is set, and the connection is aborted if that write has not completed in time.

For a small JSON payload this never fires. For a 300 MB export to somebody on hotel Wi-Fi, it absolutely does, and it shows up as a truncated file with no server-side exception worth reading. Relax it for the export path rather than globally:

```csharp
// Per-request override beats weakening the limit for the whole server.
var rateFeature = httpContext.Features.Get<IHttpMinResponseDataRateFeature>();
if (rateFeature is not null) rateFeature.MinDataRate = null;
```

### You Cannot Change the Status Code After the First Flush

The moment the first byte goes out, the response has started, headers are gone, and the status code is locked at 200. If your query throws on row 900,000, the client receives a **valid HTTP 200 with a half-written CSV file**. No exception handler can save you: `UseExceptionHandler` has nothing left to write to.

There is no elegant fix, only honest ones. Validate everything you can before the first write. Log the failure loudly server-side, because the client will not know. And if data integrity genuinely matters, write a deliberate terminator row so consumers can detect a truncated file - a footer like `#END,<rowcount>` is crude and completely effective.

### Client Disconnects Must Reach the Database

Users close tabs. When they do, `HttpContext.RequestAborted` fires - but only if you actually passed it down. An export that ignores cancellation keeps a `DataReader` open, holds a pooled connection, and in some isolation levels holds locks, for as long as the query takes to finish reading rows nobody wants. Multiply by a few impatient users clicking Export repeatedly and you have exhausted the connection pool.

Pass the token to `WriteRecordsAsync`, to `AsAsyncEnumerable` consumption, and to any `await foreach` you write yourself.

### The DbContext Is Busy for the Whole Export

A streaming query holds an open reader on that `DbContext` until the last row is consumed. Issue any other query on the same context mid-stream - a lookup to enrich a row, a permission check, an audit write - and you get the classic `A second operation was started on this context` failure. We cover the [root cause and fix for that exact exception](https://codingdroplets.com/ef-core-second-operation-started-context-production-fix) separately, but for exports the rule is simple: resolve every lookup you need *before* you start streaming, or use a second scoped context.

### Response Compression and Proxy Buffering

CSV compresses extremely well, often 8:1 or better, so enabling compression on the export path is genuinely worth it. Just confirm your reverse proxy is not buffering the whole response to compute a `Content-Length` before forwarding it - some default nginx and IIS ARR configurations do exactly that, which quietly reintroduces the memory problem you just removed, one layer up.

## When Should You Not Stream a CSV Export?

Streaming is the right default for exports that finish in seconds to a couple of minutes. It is the wrong answer in three cases:

*   **The export routinely takes longer than your gateway timeout.** Most load balancers cap an idle or total response at 60 to 300 seconds. No amount of streaming beats a proxy that hangs up.
    
*   **The user needs the file to survive a dropped connection.** A streamed download that fails at 90% is a total loss with no resume.
    
*   **The query is expensive enough to hurt your OLTP database.** A 20-minute table scan against your production write database is a capacity problem, not a serialization problem.
    

In all three, the right shape is asynchronous: accept the request, return `202 Accepted` with a status URL, generate the file in a background job, upload it to blob storage, and hand back a time-limited download link. Our [guide to long-running operations in ASP.NET Core APIs](https://codingdroplets.com/long-running-operations-aspnet-core-enterprise-guide) walks through that pattern and the polling contract that goes with it.

## Trade-Offs Worth Knowing Before You Ship

| Concern | Streaming response | Background job plus download link |
| --- | --- | --- |
| Memory | Constant | Constant |
| Time to first byte | Milliseconds | Seconds to minutes (job queue) |
| Survives gateway timeout | No | Yes |
| Resumable / retryable | No | Yes |
| Error visible to user | No, 200 already sent | Yes, job status reports failure |
| Implementation cost | One endpoint | Queue, storage, status endpoint, cleanup |

The honest recommendation from having run both: start with streaming, add a hard row-count guard, and only build the background pipeline when real usage crosses the line. Teams that reach for the job queue on day one usually spend a sprint on infrastructure for an export three people use monthly.

## What to Do Next

If you have an export endpoint in production right now, three checks are worth doing today:

1.  **Find the** `ToListAsync` **in it.** That single call is the memory ceiling.
    
2.  **Measure the worst realistic export**, not the average. Ask for the largest date range a user can select, and if the UI does not cap that range, cap it.
    
3.  **Confirm cancellation actually propagates** by starting an export and killing the client, then watching whether the query keeps running.
    

For the query side of this, [EF Core 10 query performance: AsNoTracking, compiled queries and split queries](https://codingdroplets.com/efcore-10-query-performance-asnotracking-compiled-split-queries) covers the read-path tuning that pairs with everything above.

## Frequently Asked Questions

### How do I stream a large CSV export in ASP.NET Core without loading it into memory?

Use `Results.Stream` with a `Func<Stream, Task>` callback so you write directly to the response body, and feed it from `IAsyncEnumerable<T>` rather than a materialised list. On the EF Core side that means `AsNoTracking().Select(...).AsAsyncEnumerable()`. CsvHelper's `WriteRecordsAsync` accepts an `IAsyncEnumerable<T>` source directly, so no intermediate collection is ever allocated.

### Why does my streamed CSV download get truncated on slow connections?

Almost certainly Kestrel's `MinResponseDataRate`, which defaults to 240 bytes per second with a 5 second grace period and aborts the connection when a write does not complete in time. It is enforced per write, not averaged across the response. Override it per request through `IHttpMinResponseDataRateFeature` on the export endpoint instead of disabling the limit server-wide.

### Can I return a proper error if the export fails halfway through?

No. Once the first bytes are flushed the status code is already 200 and the headers are sent, so exception middleware has nothing to write. Validate inputs and permissions before the first write, log server-side failures explicitly, and consider emitting a footer row that lets consumers detect an incomplete file.

### Should I use IAsyncEnumerable or pagination for large exports?

Use `IAsyncEnumerable` for a single continuous export: it gives you one query, one pass, and stable memory. Use keyset pagination when the client controls the loop and needs to resume, or when an intermediate gateway will not tolerate a long-lived response. Offset pagination is the wrong tool for either, because the cost of skipping rows grows with the page number.

### Does response compression work with a streamed CSV export?

Yes, and it helps a lot given how compressible CSV is. Verify that no layer between Kestrel and the client buffers the full body to compute `Content-Length`, since some default reverse-proxy configurations do, which reintroduces the memory problem at the proxy instead of in your app.

* * *

## About the Author

I'm Celin Daniel, Co-founder of [Coding Droplets](https://codingdroplets.com/). I've been building .NET and ASP.NET Core systems in production for 13+ years - APIs, distributed backends, enterprise platforms. Everything I write here comes from real shipping experience: patterns that held up, trade-offs that bit us, and lessons learned the hard way.

*   GitHub: [codingdroplets](http://github.com/codingdroplets/)
    
*   YouTube: [Coding Droplets](https://www.youtube.com/@CodingDroplets)
    
*   Website: [codingdroplets.com](https://codingdroplets.com/)
