The Stream abstraction, backing vs decorator streams, Read semantics, Position and CanSeek, disposal chains, async I/O and when to use StreamReader or Pipelines.
A Stream is a sequence of bytes you read or write in order, without needing it all in memory. Stream is an abstract class with Read/Write (sync and async), optional Seek/Position/Length (only if CanSeek), and Flush/Dispose.
FileStream, MemoryStream, NetworkStream.BufferedStream, GZipStream, BrotliStream, CryptoStream, SslStream.StreamReader, StreamWriter, BinaryReader.Key gotcha: Read may return fewer bytes than you asked for, and 0 means end of stream. Loop, or use ReadExactly (throws EndOfStreamException if the stream ends early) or ReadAtLeast, both available in .NET 10.
your code
โ string / objects
โผ
StreamReader / JsonSerializer / BinaryReader (format)
โ bytes
โผ
GZipStream / CryptoStream / SslStream (transform)
โ bytes
โผ
FileStream / NetworkStream / MemoryStream (store)
โ
โผ
OS file / socket / byte[] in memory
Because every layer is a Stream, you can compose them freely: decompress a file while you parse JSON from it, without ever holding the whole file in memory.
| Stream | CanRead | CanWrite | CanSeek | Length |
|---|---|---|---|---|
| FileStream | yes | yes | yes | yes |
| MemoryStream | yes | yes | yes | yes |
| NetworkStream | yes | yes | no | throws |
| GZipStream (decompress) | yes | no | no | throws |
| HTTP response stream | yes | no | usually no | often unknown |
Code that calls stream.Length or sets Position = 0 works in tests with MemoryStream and fails on a network or compressed stream.
An import job downloaded a 3 GB gzipped CSV with await response.Content.ReadAsByteArrayAsync(), then decompressed into a MemoryStream. Peak memory was over 10 GB and the pod was OOMKilled. We changed it to stream end to end:
HttpResponseMessage (ResponseHeadersRead) โ GZipStream โ StreamReader โ parse line by line โ batch insert
Memory stayed under 200 MB and the job started inserting rows within a second instead of after the full download.
Checks understanding of a core I/O abstraction used everywhere (files, HTTP, blobs, compression).
Green flags
Read can return fewer bytes and how to handle it.CanSeek, rewinding MemoryStream, and disposal of GZipStream.Red flags
Read always fills the buffer.int n = stream.Read(buffer, 0, 4096);
n > 0: you got between 1 and 4096 bytes. Not necessarily 4096.n == 0: end of stream.Ask for 4096 โโโบ got 1460 (one TCP segment) โโโบ ask again โโโบ got 2636
.NET 7+ added ReadExactly(buffer) (throws EndOfStreamException if the stream ends early) and ReadAtLeast(buffer, minimum).
After writing to a MemoryStream, Position is at the end. Reading returns nothing until you reset it:
write "hello" [h e l l o]
^ Position = 5
read โ 0 bytes
Position = 0 [h e l l o]
^
read โ "hello"
Disposing an outer stream disposes the inner one by default. StreamReader, StreamWriter, GZipStream and others have a leaveOpen parameter when you need the inner stream afterwards (for example, writing a gzip body into a response stream you do not own).
For compression and encryption, disposing (or Flush/FlushFinalBlock) writes the final block. If you read the inner MemoryStream before disposing the GZipStream, the data is incomplete.
FileStream opened with useAsync: true or FileOptions.Asynchronous uses real async I/O (IOCP on Windows). On Linux, file I/O is executed on the thread pool, because regular files do not support epoll readiness.FileStream for much better performance and correct Position tracking with async calls.CopyToAsync, ReadAsync(Memory<byte>) and WriteAsync(ReadOnlyMemory<byte>) overloads, which avoid extra allocations.System.IO.Pipelines (PipeReader/PipeWriter) handles buffer management and partial messages for you. Kestrel uses it internally. It is a good fit for high-throughput protocol parsing; for ordinary file and HTTP work, Stream is enough.
"hello" to a MemoryStream and call new StreamReader(ms).ReadToEnd() before and after ms.Position = 0.MemoryStream with GZipStream and print ms.Length before and after disposing the GZipStream.NetworkStream (or HttpClient response stream) into a 64 KiB buffer in a loop and log each n to see partial reads.// Stream a gzipped CSV from HTTP to the database without loading it into memory
using var response = await http.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, ct);
response.EnsureSuccessStatusCode();
await using var body = await response.Content.ReadAsStreamAsync(ct);
await using var gzip = new GZipStream(body, CompressionMode.Decompress);
using var reader = new StreamReader(gzip, Encoding.UTF8);
var batch = new List<Order>(capacity: 5_000);
while (await reader.ReadLineAsync(ct) is { } line)
{
batch.Add(Order.Parse(line));
if (batch.Count == batch.Capacity)
{
await repository.BulkInsertAsync(batch, ct);
batch.Clear();
}
}
if (batch.Count > 0) await repository.BulkInsertAsync(batch, ct);
// Reading a fixed-size header correctly
var header = new byte[16];
await stream.ReadExactlyAsync(header, ct); // .NET 7+: loops for you
// Manual equivalent on older code
int read = 0;
while (read < header.Length)
{
int n = await stream.ReadAsync(header.AsMemory(read), ct);
if (n == 0) throw new EndOfStreamException();
read += n;
}
// Compress to memory: dispose the GZipStream before using the bytes
var output = new MemoryStream();
await using (var gzip = new GZipStream(output, CompressionLevel.Optimal, leaveOpen: true))
{
await JsonSerializer.SerializeAsync(gzip, report, cancellationToken: ct);
} // final block written here
output.Position = 0; // rewind before reading
return output;
Mistake: Assuming Read fills the buffer.
Why it is wrong: Network, pipe and compressed streams often return fewer bytes, so the code silently processes partial data.
Instead: Loop until you have what you need, or use ReadExactly or ReadAtLeast.
Mistake: Loading whole files or responses into memory (ReadAllBytes, ReadAsByteArrayAsync, ToArray) before processing.
Why it is wrong: Large inputs cause huge Large Object Heap allocations and OOMKilled pods, and nothing starts until the whole download finishes.
Instead: Compose streams end to end and process incrementally.
Mistake: Forgetting to rewind a MemoryStream.
Why it is wrong: After writing, Position is at the end, so a reader sees nothing.
Instead: Set Position = 0 before reading.
Mistake: Reading the inner stream before disposing GZipStream or CryptoStream.
Why it is wrong: The final block is only written on dispose or FlushFinalBlock, so the data is truncated or corrupt.
Instead: Dispose or flush the wrapper first, with leaveOpen: true if you still need the inner stream.
Mistake: Using synchronous Read or CopyTo on request paths in ASP.NET Core.
Why it is wrong: It blocks thread-pool threads while waiting on I/O, and Kestrel disallows synchronous body I/O by default.
Instead: Use ReadAsync, WriteAsync and CopyToAsync with a CancellationToken.
Streams are about memory and latency shape: processing starts as soon as the first bytes arrive and memory stays flat whatever the input size. I design import, export and proxy paths as stream pipelines and treat ToArray() on unbounded input as a review red flag.
| Choice | Benefit | Cost | When to use it |
|---|---|---|---|
Buffer whole input (byte[], MemoryStream) |
Simple, seekable | Memory grows with input, LOH pressure | Small, bounded payloads |
| Stream pipeline | Flat memory, early start | Forward-only, harder retries | Files, HTTP bodies, blobs |
Bigger FileStream buffer |
Fewer system calls | More memory per stream | Large sequential copies |
BufferedStream |
Batches small reads or writes | Pointless over already-buffered streams | Many tiny writes to a network stream |
System.IO.Pipelines |
Pooled buffers, handles partial messages | More complex API | High-throughput parsers |
The subtle parts are contracts: partial reads, CanSeek, disposal order for transforming streams, and leaveOpen ownership. I write APIs that accept Stream without assuming seekability and test them with a non-seekable wrapper to catch hidden Length calls.
Data passes through several buffers: your own buffer โ StreamWriter/FileStream buffer (4 KiB default for FileStream) โ the OS page cache โ the disk's own cache โ the storage media.
Flush() moves data from the .NET buffer into the OS. It survives a process crash, not a power loss or kernel crash.Flush(flushToDisk: true) calls fsync/FlushFileBuffers, asking the OS and device to persist it.Flush(true), then rename over the target (File.Move(temp, path, overwrite: true)), so readers see either the old or the new file, never half of one.Buffers exist for speed: many small writes become a few large system calls and disk operations.
Never load the file. Stream it: open with a large buffer and SequentialScan, parse line by line with a fast CSV parser (or PipeReader + UTF-8 parsing), and push rows into a bounded Channel so the reader cannot outrun the writers. Insert in batches (e.g. 10,000โ50,000 rows) with SqlBulkCopy into a staging table, then merge into the real table in one transaction.
Make it resumable (checkpoint byte offset or line number per committed batch), send bad rows to a reject file with line numbers, make the load idempotent (file hash, staging swap), and emit progress metrics. Memory stays at a few hundred MB regardless of file size.
In .NET: use a normal FileStream (optionally FileOptions.SequentialScan and a bigger buffer) for sequential work. For random work use RandomAccess.Read/Write with a SafeFileHandle (offset-based, thread-safe, no shared Position), FileOptions.RandomAccess, or a MemoryMappedFile when you want the OS to page data in for you.
How to tell it (STAR)
**S:** Our [nightly import] kept getting OOMKilled after the supplier's file grew to [3 GB]. **T:** I needed it to finish within the [2 GB] pod limit. **A:** The code downloaded the whole response, decompressed it into memory and then copied it again. I changed it to stream the HTTP body through `GZipStream` and `StreamReader` and insert in batches. **R:** Peak memory fell to [~200 MB], the import finished [40%] faster because work started immediately, and it now handles any file size.
Last updated 23 Oct 2026.