HttpClient Streaming in C#: HttpCompletionOption, ReadAsStreamAsync, and Server-Sent Events
If you have ever used HttpClient to download a large file or fetch a big JSON payload, you may have noticed your app's memory spike dramatically. That is the default buffering behavior at work. Understanding httpclient streaming in C# is how you fix it -- and it is easier than most developers expect.
In .NET 10, the tools for streaming HTTP responses are mature and ergonomic. HttpCompletionOption.ResponseHeadersRead, ReadAsStreamAsync, JsonSerializer.DeserializeAsyncEnumerable, and first-class Server-Sent Events (SSE) support all work together to let you process gigabytes of data with a fraction of the memory cost. This article walks through each piece, explains why it works the way it does, and gives you complete, copy-pasteable code examples.
If you want the full picture of what HttpClient can do, the HttpClient in C#: The Complete Guide is a great starting point before diving into the streaming specifics here.
The Default Problem: Buffering Entire Responses in Memory
By default, HttpClient does something that surprises many developers. When you call GetStringAsync, GetByteArrayAsync, or SendAsync with the default completion option, the entire response body is downloaded and buffered into memory before your code gets control back.
Here is what that looks like:
using var client = new HttpClient();
// The entire response body is buffered in memory before this line returns
string json = await client.GetStringAsync("https://api.example.com/large-dataset");
// By this point you might have 500MB in memory -- and you haven't even
// started processing it yet
For small responses, this is fine. For a 10MB JSON file, it is a little wasteful. For a 1GB file download, a real-time data stream, or a Server-Sent Events feed that never ends, it is a serious problem.
The memory usage is roughly proportional to the response size. There is no way around that with buffered reads -- .NET has to store the data somewhere. The streaming approach changes the model entirely: instead of "download everything, then process," you get "process as it arrives."
HttpCompletionOption.ResponseContentRead vs ResponseHeadersRead
HttpCompletionOption is an enum with two values, and the difference between them is the key to httpclient streaming in C#.
ResponseContentRead (the default) means: "complete the task only after the entire response body has been downloaded and buffered." Your await does not return until the last byte of the body is in memory. This is the safe, simple default -- but it means the full payload is always in memory at once.
ResponseHeadersRead means: "complete the task as soon as the response headers arrive, without waiting for the body." Your await returns quickly. The body has not been downloaded yet -- you get a Stream you can read incrementally, at your own pace.
A good analogy: imagine ordering a book. ResponseContentRead is waiting outside the publisher's warehouse until the entire print run is complete, then taking everything at once. ResponseHeadersRead is getting a call as soon as the first page comes off the press -- you can start reading while printing continues.
Here is how to opt in:
using var client = new HttpClient();
// Pass ResponseHeadersRead to get control back as soon as headers arrive
using var response = await client.SendAsync(
new HttpRequestMessage(HttpMethod.Get, "https://api.example.com/large-dataset"),
HttpCompletionOption.ResponseHeadersRead);
response.EnsureSuccessStatusCode();
// response.Content is now a Stream source -- not a buffer
The critical point is that response.Content is still connected to the network socket at this stage. Data flows from the server through the stream as you read it. If you never read the stream, the data never enters your process memory at scale.
Reading the Body as a Stream with ReadAsStreamAsync
Once you have a response obtained with ResponseHeadersRead, ReadAsStreamAsync gives you direct access to the underlying network stream.
using System.Net.Http;
using var client = new HttpClient();
using var response = await client.SendAsync(
new HttpRequestMessage(HttpMethod.Get, "https://logs.example.com/stream"),
HttpCompletionOption.ResponseHeadersRead);
response.EnsureSuccessStatusCode();
// ReadAsStreamAsync returns the raw network stream -- no buffering
await using var stream = await response.Content.ReadAsStreamAsync();
using var reader = new StreamReader(stream);
while (!reader.EndOfStream)
{
var line = await reader.ReadLineAsync();
if (line is not null)
{
Console.WriteLine(line);
}
}
Notice the await using on the stream. Because ReadAsStreamAsync returns a Stream that wraps an active network connection, disposing it properly is important -- it signals to the HTTP infrastructure that you are done and allows the connection to be returned to the pool.
You can also read the stream into a buffer manually if you need byte-level control:
await using var stream = await response.Content.ReadAsStreamAsync();
var buffer = new byte[4096];
int bytesRead;
while ((bytesRead = await stream.ReadAsync(buffer)) > 0)
{
// Process buffer[0..bytesRead]
}
This is the foundation of httpclient streaming in C#. Everything else -- progress reporting, JSON deserialization, SSE -- builds on top of this pattern.
Memory Efficiency: Before and After
The memory difference between the two approaches is stark. Here is a side-by-side comparison showing the same operation in both modes.
Before -- buffered (ResponseContentRead, the default):
using var client = new HttpClient();
// Memory spike: the ENTIRE response is loaded before you get control
byte[] data = await client.GetByteArrayAsync("https://files.example.com/export-1gb.bin");
// Peak memory = response size (e.g., 1GB for a 1GB file)
Console.WriteLine($"Downloaded {data.Length:N0} bytes");
After -- streaming (ResponseHeadersRead + ReadAsStreamAsync):
using var client = new HttpClient();
using var response = await client.SendAsync(
new HttpRequestMessage(HttpMethod.Get, "https://files.example.com/export-1gb.bin"),
HttpCompletionOption.ResponseHeadersRead);
response.EnsureSuccessStatusCode();
await using var stream = await response.Content.ReadAsStreamAsync();
await using var fileStream = new FileStream("export-1gb.bin", FileMode.Create);
// Copy streams in chunks -- only one buffer in memory at a time
await stream.CopyToAsync(fileStream);
// Peak memory ≈ buffer size (default 81,920 bytes -- about 80KB)
Console.WriteLine("Download complete");
The memory profile is completely different. In the buffered version, peak memory grows with the response size -- a 1GB response means 1GB on the heap. In the streaming version, peak memory is roughly constant at the buffer size (around 80KB with CopyToAsync's default buffer). The total data transferred is identical; only the memory profile changes.
For large JSON payloads, the difference is even more pronounced because the buffered approach allocates both the raw bytes and the deserialized object graph before you can touch either.
Streaming JSON Deserialization with DeserializeAsyncEnumerable
System.Text.Json has supported streaming deserialization through JsonSerializer.DeserializeAsyncEnumerable<T> since .NET 5. This method reads a JSON array from a stream and yields each element as it becomes available, without loading the entire array into memory first.
The server-side response must be a JSON array (or newline-delimited JSON). Given that, the client side looks like this:
using System.Net.Http;
using System.Text.Json;
public sealed record WeatherReading(
string Station,
DateTimeOffset Timestamp,
double TemperatureCelsius);
public static async Task ProcessWeatherDataAsync(HttpClient client, CancellationToken cancellationToken)
{
using var response = await client.SendAsync(
new HttpRequestMessage(HttpMethod.Get, "https://api.example.com/weather/readings"),
HttpCompletionOption.ResponseHeadersRead,
cancellationToken);
response.EnsureSuccessStatusCode();
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken);
// Each WeatherReading is yielded as its JSON element is parsed --
// the rest of the array has not been downloaded yet
await foreach (var reading in JsonSerializer.DeserializeAsyncEnumerable<WeatherReading>(
stream,
cancellationToken: cancellationToken))
{
await ProcessReadingAsync(reading, cancellationToken);
}
}
private static async Task ProcessReadingAsync(WeatherReading reading, CancellationToken cancellationToken)
{
// Your business logic here -- called for each record as it arrives
Console.WriteLine($"{reading.Station}: {reading.TemperatureCelsius:F1}°C at {reading.Timestamp}");
await Task.Yield(); // simulate async work
}
This pattern pairs httpclient streaming in C# with incremental deserialization. A JSON array with a million records streams through one record at a time, with memory overhead proportional to one record -- not one million.
One thing to plan for is malformed JSON in the stream. If the server emits a corrupt or truncated element midway through the array, DeserializeAsyncEnumerable will throw a JsonException at the point where parsing fails. Any records already yielded before the bad element have already been processed -- you cannot "unprocess" them. The right strategy is to wrap the await foreach in a try/catch and decide whether a parse failure is terminal or recoverable:
public static async Task ProcessWeatherDataSafeAsync(HttpClient client, CancellationToken cancellationToken)
{
using var response = await client.SendAsync(
new HttpRequestMessage(HttpMethod.Get, "https://api.example.com/weather/readings"),
HttpCompletionOption.ResponseHeadersRead,
cancellationToken);
response.EnsureSuccessStatusCode();
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken);
try
{
await foreach (var reading in JsonSerializer.DeserializeAsyncEnumerable<WeatherReading>(
stream,
cancellationToken: cancellationToken))
{
if (reading is null) continue; // null elements are possible in nullable arrays
await ProcessReadingAsync(reading, cancellationToken);
}
}
catch (JsonException ex)
{
// Partial data has already been processed -- log and decide whether to retry
Console.WriteLine($"JSON parse failed mid-stream: {ex.Message}");
}
}
This matters in practice because streaming APIs sometimes emit incomplete responses under load. Treating the first successfully deserialized records as useful data -- rather than discarding everything -- is usually the right call for analytics and telemetry pipelines where partial data is still valuable.
Downloading Large Files with Progress Reporting
Streaming also enables progress reporting, which is impossible when you are using buffered helpers like GetByteArrayAsync (the download has already happened by the time you get the bytes). With streaming, you observe every chunk.
using System.Net.Http;
public static async Task DownloadWithProgressAsync(
HttpClient client,
string url,
string outputPath,
IProgress<double>? progress,
CancellationToken cancellationToken = default)
{
using var response = await client.SendAsync(
new HttpRequestMessage(HttpMethod.Get, url),
HttpCompletionOption.ResponseHeadersRead,
cancellationToken);
response.EnsureSuccessStatusCode();
// Content-Length may not be present (e.g., chunked transfer encoding)
long? totalBytes = response.Content.Headers.ContentLength;
await using var networkStream = await response.Content.ReadAsStreamAsync(cancellationToken);
await using var fileStream = new FileStream(
outputPath,
FileMode.Create,
FileAccess.Write,
FileShare.None,
bufferSize: 81920,
useAsync: true);
var buffer = new byte[81920];
long bytesDownloaded = 0;
int read;
while ((read = await networkStream.ReadAsync(buffer, cancellationToken)) > 0)
{
await fileStream.WriteAsync(buffer.AsMemory(0, read), cancellationToken);
bytesDownloaded += read;
if (progress is not null && totalBytes.HasValue)
{
progress.Report((double)bytesDownloaded / totalBytes.Value * 100.0);
}
}
}
// Usage:
var progressReporter = new Progress<double>(pct => Console.Write($"
{pct:F1}%"));
await DownloadWithProgressAsync(client, "https://files.example.com/large.zip", "large.zip", progressReporter);
The IProgress<T> pattern integrates cleanly with UI frameworks too. Pass a Progress<double> that marshals updates to the UI thread, and you get real-time download progress in a WPF or MAUI app without extra plumbing.
Server-Sent Events (SSE) with HttpClient in .NET 10
Server-Sent Events are a lightweight protocol for unidirectional real-time data from server to client over plain HTTP. Each event is a text line in the format data: <payload>, separated by blank lines. The connection stays open indefinitely and the server pushes events as they occur.
Consuming SSE with httpclient streaming in C# is a natural fit for ResponseHeadersRead. Since .NET 9, you can also use the built-in SseParser from System.Net.ServerSentEvents for robust parsing, but let's start with the low-level approach to understand the protocol, then show the idiomatic way.
Low-level SSE reader:
using System.Net.Http;
using System.Net.Http.Headers;
using System.Runtime.CompilerServices;
public static async IAsyncEnumerable<string> ReadSseDataAsync(
HttpClient client,
string url,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
using var request = new HttpRequestMessage(HttpMethod.Get, url);
// Servers expect Accept: text/event-stream to enable SSE mode
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("text/event-stream"));
using var response = await client.SendAsync(
request,
HttpCompletionOption.ResponseHeadersRead,
cancellationToken);
response.EnsureSuccessStatusCode();
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken);
using var reader = new StreamReader(stream);
while (!cancellationToken.IsCancellationRequested)
{
var line = await reader.ReadLineAsync(cancellationToken);
if (line is null) break; // server closed the connection
if (line.StartsWith("data: ", StringComparison.Ordinal))
{
yield return line["data: ".Length..];
}
// Skip event:, id:, retry:, and blank separator lines
}
}
Idiomatic .NET 10 approach using SseParser:
using System.Net.Http;
using System.Net.Http.Headers;
using System.Net.ServerSentEvents;
public static async Task ConsumeStockPricesAsync(HttpClient client, CancellationToken cancellationToken)
{
using var request = new HttpRequestMessage(HttpMethod.Get, "https://api.example.com/stocks/stream");
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("text/event-stream"));
using var response = await client.SendAsync(
request,
HttpCompletionOption.ResponseHeadersRead,
cancellationToken);
response.EnsureSuccessStatusCode();
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken);
// SseParser handles multi-line data, reconnection IDs, and event types
await foreach (SseItem<string> sseItem in SseParser.Create(stream).EnumerateAsync(cancellationToken))
{
Console.WriteLine($"[{sseItem.EventType}] {sseItem.Data}");
}
}
The SseParser from System.Net.ServerSentEvents handles the full SSE spec -- multi-line data fields, event type filtering, and id tracking -- so you do not have to parse the protocol yourself.
SseParser exposes a few key members worth knowing:
SseParser.Create(stream)-- creates a parser for a plainStream. Use this when you want the defaultstringdata type.SseParser.Create(stream, dataParser)-- creates a parser with a customdataParserdelegate, letting you deserializeSseItem<T>directly into a typed object instead of raw strings..EnumerateAsync(cancellationToken)-- returns anIAsyncEnumerable<SseItem<T>>you can consume withawait foreach.- Each
SseItem<T>exposes.EventType(theevent:field, or"message"by default),.Data(the deserialized payload), and.EventId(theid:field for reconnection).
A typed SSE parser looks like this:
await foreach (SseItem<StockPrice> item in SseParser.Create(stream,
(eventType, data) => JsonSerializer.Deserialize<StockPrice>(data)!)
.EnumerateAsync(cancellationToken))
{
Console.WriteLine($"{item.EventType}: {item.Data.Symbol} @ {item.Data.Price}");
}
This eliminates a manual JsonSerializer.Deserialize call inside the loop and keeps the parsing logic in one place.
For building the server side of this, the ASP.NET Core Web API in .NET: The Complete Guide covers how to push SSE responses from an ASP.NET Core endpoint. And ASP.NET Core Middleware: Building and Using the Request Pipeline is useful if you want to understand how the request pipeline handles long-lived streaming connections on the server.
Performance Considerations: Streaming vs Buffering
Not every HTTP response needs to be streamed. Choosing the wrong approach adds code complexity without any real benefit. Here is a quick decision guide:
| Scenario | Recommended approach | Why |
|---|---|---|
| Response is under ~1MB | GetStringAsync / GetByteArrayAsync |
Buffering is fast and simple; the overhead is negligible |
| Response is over ~10MB | ResponseHeadersRead + streaming |
Prevents large heap allocations and GC pressure |
| JSON array with many items | DeserializeAsyncEnumerable |
Process each item before the next arrives; constant memory |
| File download | ResponseHeadersRead + manual buffer loop |
Enables progress reporting and avoids holding file content in memory |
| Server-Sent Events or live feeds | ResponseHeadersRead + SseParser |
The connection is unbounded in time; buffering is not an option |
| Small, frequent API calls (REST) | Default buffering | Simplicity wins; streaming adds overhead for tiny payloads |
The key insight is that streaming shines when either the size is large or the duration is unbounded. For routine API calls returning a few kilobytes of JSON, the default buffered helpers are perfectly fine and easier to work with.
CancellationToken and Streaming: Clean Cancellation Patterns
Streaming connections are long-lived by nature. A clean cancellation story is not optional -- it is essential. Failing to cancel gracefully can leak sockets, stall thread pool threads, or silently drop data.
Every streaming method in the examples above accepts a CancellationToken. The important thing is to pass it everywhere -- into SendAsync, into ReadAsStreamAsync, and into each ReadAsync or ReadLineAsync call.
using System.Net.Http;
public static async Task StreamWithTimeoutAsync(
HttpClient client,
string url,
CancellationToken externalCancellation)
{
// Combine a 30-second timeout with any external cancellation signal
using var timeoutCts = new CancellationTokenSource(TimeSpan.FromSeconds(30));
using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(
externalCancellation, timeoutCts.Token);
CancellationToken token = linkedCts.Token;
try
{
using var response = await client.SendAsync(
new HttpRequestMessage(HttpMethod.Get, url),
HttpCompletionOption.ResponseHeadersRead,
token);
response.EnsureSuccessStatusCode();
await using var stream = await response.Content.ReadAsStreamAsync(token);
using var reader = new StreamReader(stream);
while (!reader.EndOfStream)
{
token.ThrowIfCancellationRequested();
var line = await reader.ReadLineAsync(token);
Console.WriteLine(line);
}
}
catch (OperationCanceledException) when (timeoutCts.IsCancellationRequested)
{
Console.WriteLine("Stream read timed out after 30 seconds.");
}
catch (OperationCanceledException)
{
Console.WriteLine("Stream read was cancelled by the caller.");
}
}
Handling exceptions carefully while streaming is important -- an OperationCanceledException is not a bug, it is the intended exit path. For more patterns around exception handling in C#, How To Handle Exceptions in CSharp has solid coverage of the catch filter syntax and structured error handling.
Two things to watch out for:
- Avoid setting
HttpClient.TimeouttoTimeout.InfiniteTimeSpanwithout a per-requestCancellationTokensafety net -- it disables the overall request timeout for the entire client. When combined with aCancellationTokenSourceper request (as in the complete example below), setting it toInfiniteTimeSpanis the correct approach for long-lived streaming downloads. - Always dispose the response and stream on cancellation -- the
usingandawait usingstatements handle this automatically, even when an exception is thrown.
Backpressure and IAsyncEnumerable Pipelines
One subtle but important advantage of IAsyncEnumerable for httpclient streaming in C# is that it provides natural backpressure. The consumer controls the pace. If your processing is slow, the network read slows down to match -- you never build up an unbounded queue of unprocessed items.
Here is how to expose a streaming HTTP response as a typed IAsyncEnumerable<T> that any caller can consume:
using System.Net.Http;
using System.Runtime.CompilerServices;
using System.Text.Json;
public static async IAsyncEnumerable<T> StreamJsonAsync<T>(
HttpClient client,
string url,
JsonSerializerOptions? options = null,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
using var response = await client.SendAsync(
new HttpRequestMessage(HttpMethod.Get, url),
HttpCompletionOption.ResponseHeadersRead,
cancellationToken);
response.EnsureSuccessStatusCode();
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken);
await foreach (var item in JsonSerializer.DeserializeAsyncEnumerable<T>(
stream,
options,
cancellationToken))
{
yield return item;
}
}
// Caller controls the pace -- a slow ProcessAsync call slows down the whole pipeline
await foreach (var record in StreamJsonAsync<LogEntry>(client, "https://api.example.com/logs"))
{
await ProcessAsync(record); // next network read waits until this returns
}
The [EnumeratorCancellation] attribute on the token parameter is important. It allows await foreach (... cancellationToken: token) to work correctly, injecting the cancellation token into the IAsyncEnumerator<T> without requiring a separate overload.
Contrast this with a Channel<T> or BlockingCollection<T> approach, where a producer pushes items as fast as the network delivers them, potentially building up a large in-memory queue if the consumer is slow. The IAsyncEnumerable pull model avoids that entirely.
Complete Example: Streaming a 1GB Response Without Buffering
Here is a complete, self-contained example that combines every technique from this article. It streams a 1GB response, reports progress, supports cancellation, and never holds more than a small buffer in memory.
using System.Net.Http;
using System.Net.Http.Headers;
public sealed record StreamingProgress(
long BytesRead,
long? TotalBytes,
double? PercentComplete)
{
public override string ToString() =>
TotalBytes.HasValue
? $"{BytesRead:N0} / {TotalBytes.Value:N0} bytes ({PercentComplete:F1}%)"
: $"{BytesRead:N0} bytes (total unknown)";
}
public sealed class LargeFileStreamer
{
private const int BufferSize = 131_072; // 128KB
private readonly HttpClient _client;
public LargeFileStreamer(HttpClient client)
{
_client = client;
}
/// <summary>
/// Downloads a large file to disk without buffering the response body in memory.
/// Peak memory usage is approximately equal to the buffer size (128KB), regardless
/// of the response size.
/// </summary>
public async Task DownloadAsync(
string url,
string outputPath,
IProgress<StreamingProgress>? progress = null,
CancellationToken cancellationToken = default)
{
using var request = new HttpRequestMessage(HttpMethod.Get, url);
// Opt in to streaming -- task completes when headers arrive, not when body is buffered
using var response = await _client.SendAsync(
request,
HttpCompletionOption.ResponseHeadersRead,
cancellationToken);
response.EnsureSuccessStatusCode();
long? totalBytes = response.Content.Headers.ContentLength;
await using var networkStream = await response.Content.ReadAsStreamAsync(cancellationToken);
await using var fileStream = new FileStream(
outputPath,
FileMode.Create,
FileAccess.Write,
FileShare.None,
bufferSize: BufferSize,
useAsync: true);
var buffer = new byte[BufferSize];
long totalBytesRead = 0;
int bytesRead;
while ((bytesRead = await networkStream.ReadAsync(buffer, cancellationToken)) > 0)
{
// Write only the bytes received in this chunk
await fileStream.WriteAsync(buffer.AsMemory(0, bytesRead), cancellationToken);
totalBytesRead += bytesRead;
if (progress is not null)
{
double? percent = totalBytes.HasValue
? (double)totalBytesRead / totalBytes.Value * 100.0
: null;
progress.Report(new StreamingProgress(totalBytesRead, totalBytes, percent));
}
}
}
}
// ------ Program entry point ------
var handler = new SocketsHttpHandler
{
// Allow responses to stream indefinitely without a read timeout
ResponseDrainTimeout = Timeout.InfiniteTimeSpan
};
var client = new HttpClient(handler)
{
// Disable the overall request timeout -- the CancellationTokenSource above is the per-request safety net
Timeout = Timeout.InfiniteTimeSpan
};
var streamer = new LargeFileStreamer(client);
using var cts = new CancellationTokenSource();
Console.CancelKeyPress += (_, e) => { e.Cancel = true; cts.Cancel(); };
var progress = new Progress<StreamingProgress>(p => Console.Write($"
{p} "));
try
{
await streamer.DownloadAsync(
"https://files.example.com/one-gigabyte-export.bin",
"one-gigabyte-export.bin",
progress,
cts.Token);
Console.WriteLine("
Done.");
}
catch (OperationCanceledException)
{
Console.WriteLine("
Cancelled.");
}
A few things worth calling out in this example:
HttpClient.Timeout = Timeout.InfiniteTimeSpanis used intentionally here because we are streaming a large file and do not want the read to abort partway through. Per-request cancellation viaCancellationTokenSourceprovides the actual safety net.ResponseDrainTimeout = Timeout.InfiniteTimeSpanonSocketsHttpHandlerprevents the infrastructure from abandoning the stream early when draining is slow.buffer.AsMemory(0, bytesRead)ensures only the bytes actually read in this chunk are written, even if the buffer was not fully filled (which happens on the last chunk).
Frequently Asked Questions
What is HttpCompletionOption.ResponseHeadersRead in C#?
HttpCompletionOption.ResponseHeadersRead tells HttpClient to complete the SendAsync task as soon as the HTTP response headers are received, without waiting for the response body to be fully downloaded. This lets you access the response body as a stream and process it incrementally, which is the foundation of httpclient streaming in C#.
How do I read a streaming HTTP response in C# without loading it all into memory?
Pass HttpCompletionOption.ResponseHeadersRead to SendAsync, then call ReadAsStreamAsync() on the response content. Read from the resulting stream in chunks using ReadAsync or copy it to a file with CopyToAsync. The response body never materializes as a complete byte array in memory.
Can I use ReadAsStreamAsync with cancellation in .NET 10?
Yes. ReadAsStreamAsync accepts a CancellationToken directly since .NET 5. Pass the token to SendAsync, ReadAsStreamAsync, and each subsequent ReadAsync or ReadLineAsync call. When the token is cancelled, the stream is closed and an OperationCanceledException is thrown from the pending read.
How does httpclient streaming in C# work with Server-Sent Events?
Use ResponseHeadersRead to get the long-lived response, set the Accept: text/event-stream header, and then read lines from the stream continuously. Since .NET 9, SseParser.Create(stream).EnumerateAsync() from System.Net.ServerSentEvents handles the SSE wire format for you, yielding SseItem<string> objects for each event.
What is the memory difference between ResponseContentRead and ResponseHeadersRead?
With ResponseContentRead, peak memory usage equals the response body size -- a 1GB response means 1GB on the heap. With ResponseHeadersRead and streaming reads, peak memory usage is approximately equal to your read buffer size (typically 4KB to 128KB), regardless of how large the total response is.
How do I report progress while downloading a large file with HttpClient?
Use ResponseHeadersRead and read the body manually in a loop. Check response.Content.Headers.ContentLength for the total size, track bytes read per chunk, and report progress via IProgress<T>. ContentLength may be null for chunked transfer responses, in which case you can only report bytes downloaded, not percentage.
What is IAsyncEnumerable and how does it help with HTTP streaming in C#?
IAsyncEnumerable<T> is an interface for asynchronous sequences -- like IEnumerable<T> but each element is awaited. When combined with httpclient streaming in C#, it provides natural backpressure: the next network read does not happen until the consumer calls MoveNextAsync(). This prevents a fast network from overwhelming a slow processor and keeps memory usage predictable.
Wrapping Up
Streaming HTTP responses in C# is one of those topics where the default behavior works fine until it very much does not. Once you are dealing with large files, long-lived event streams, or APIs that return paginated-in-spirit responses as one massive JSON array, the buffering model falls apart.
The good news is that the fix is straightforward. HttpCompletionOption.ResponseHeadersRead opts you out of automatic buffering. ReadAsStreamAsync gives you the raw network stream. JsonSerializer.DeserializeAsyncEnumerable handles structured data without materializing the full collection. And IAsyncEnumerable<T> ties it all together into pipelines that respect backpressure and cancellation.
These are not advanced or exotic APIs -- they are the right tools for any significant HTTP data transfer in .NET 10. The more familiar they become, the less likely you are to accidentally buffer a 500MB response during a peak traffic window.
To explore more about how .NET handles HTTP communication, the HttpClient in C#: The Complete Guide covers the full breadth of HttpClient usage patterns in .NET 10.

