Skip to main content

Command Palette

Search for a command to run...

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

Updated
11 min readView as Markdown
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 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 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

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

The naive implementation looks harmless:

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 takes a callback that receives the raw response Stream, so nothing is buffered on your behalf:

// 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.

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 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:

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 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:

// 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 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 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 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. 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.

More from this blog

C

Coding Droplets

325 posts

Coding Droplets is your go-to resource for .NET and ASP.NET Core development. Whether you're just starting out or building production systems, you'll find practical guides, real-world patterns, and clear explanations that actually make sense.

From beginner-friendly tutorials to advanced architecture decisions. We publish fresh .NET content every day to help you grow at every stage of your career.