Every .NET developer has written some variation of the same loop: read bytes from a stream into a buffer, scan for a delimiter, handle the case where the message spans two reads, resize the buffer when it's too small, and remember to return the rented array to the pool. It's tedious. It's error-prone. And every time you think you've nailed all the edge cases, a colleague finds a new way to lose data or leak memory.
System.IO.Pipelines exists precisely to kill that boilerplate. It was born inside Kestrel -- ASP.NET Core's web server -- and extracted into a first-class BCL API in .NET Core 2.1. Today it underpins Kestrel, SignalR, gRPC, and every high-throughput networking stack in the ecosystem. Yet most application developers have never touched it directly.
If you've ever written a TCP server, a custom protocol parser, or a file-processing pipeline that needs to handle partial reads gracefully, this API will change how you think about streaming data in .NET.
The problem with raw streams
Consider the classic "read lines from a socket" scenario. A naive first attempt looks harmless enough:
async Task ProcessLinesAsync(NetworkStream stream)
{
var buffer = new byte[1024];
var bytesRead = await stream.ReadAsync(buffer);
ProcessLine(buffer.AsSpan(0, bytesRead));
}
This code has at least four bugs hiding in plain sight:
- A single
ReadAsynccall might not return a complete line. - It might return multiple lines in one go.
- A line longer than 1,024 bytes silently truncates.
- A fresh
byte[1024]is allocated on every call.
Fixing all four requires buffer pooling, tracking how much data has been consumed versus buffered, resizing when the buffer fills up, and compacting to reclaim space. By the time you've handled every edge case, the parsing logic -- the bit you actually care about -- is buried under 50+ lines of buffer management.
Enter the Pipe
System.IO.Pipelines splits the problem into two cooperating halves: a writer that fills a shared buffer, and a reader that consumes from it. A Pipe object connects them:
var pipe = new Pipe();
PipeReader reader = pipe.Reader;
PipeWriter writer = pipe.Writer;
The Pipe owns the memory. It rents from an internal pool, tracks what has been written, what has been consumed, and what has merely been examined. You never allocate a byte[] yourself.
The two-loop pattern
The canonical pattern for a TCP server decomposes into two concurrent tasks: one that fills the pipe from the network, and one that reads from the pipe and parses messages.
async Task ProcessConnectionAsync(Socket socket)
{
var pipe = new Pipe();
Task writing = FillPipeAsync(socket, pipe.Writer);
Task reading = ParsePipeAsync(pipe.Reader);
await Task.WhenAll(reading, writing);
}
Writing into the pipe
The writer loop requests memory from the PipeWriter, receives data from the socket directly into that memory, then advances and flushes:
async Task FillPipeAsync(Socket socket, PipeWriter writer)
{
const int minimumBufferSize = 512;
while (true)
{
Memory<byte> memory = writer.GetMemory(minimumBufferSize);
int bytesRead = await socket.ReceiveAsync(memory, SocketFlags.None);
if (bytesRead == 0)
break;
writer.Advance(bytesRead);
FlushResult result = await writer.FlushAsync();
if (result.IsCompleted)
break;
}
await writer.CompleteAsync();
}
Three operations, three purposes:
GetMemoryrents a buffer of at least the requested size from the pipe's pool.Advancetells the writer how many bytes were actually written.FlushAsyncmakes those bytes visible to the reader and applies backpressure if the reader is falling behind.
No byte arrays. No manual pooling. No copies.
Reading from the pipe
The reader loop mirrors the writer. It reads a ReadResult, scans for delimiters, processes complete messages, then tells the pipe what was consumed:
async Task ParsePipeAsync(PipeReader reader)
{
while (true)
{
ReadResult result = await reader.ReadAsync();
ReadOnlySequence<byte> buffer = result.Buffer;
while (TryReadLine(ref buffer, out ReadOnlySequence<byte> line))
{
ProcessLine(line);
}
reader.AdvanceTo(buffer.Start, buffer.End);
if (result.IsCompleted)
break;
}
await reader.CompleteAsync();
}
bool TryReadLine(ref ReadOnlySequence<byte> buffer, out ReadOnlySequence<byte> line)
{
SequencePosition? position = buffer.PositionOf((byte)'\n');
if (position is null)
{
line = default;
return false;
}
line = buffer.Slice(0, position.Value);
buffer = buffer.Slice(buffer.GetPosition(1, position.Value));
return true;
}
The key call is AdvanceTo. It takes two positions:
- consumed -- everything up to this point can be released back to the pool.
- examined -- everything up to this point has been scanned. The next
ReadAsyncwill not return until new data arrives beyond this position.
Getting these two values right is critical. More on that shortly.
Understanding ReadOnlySequence<byte>
When data spans multiple internal buffer segments, the ReadResult.Buffer is a ReadOnlySequence<byte> -- a linked list of ReadOnlyMemory<byte> chunks. Most of the time it behaves like a contiguous span, but you cannot cast it to a Span<byte> without copying when it spans segments.
For single-segment sequences (the common case), you can fast-path:
void ParseMessage(ReadOnlySequence<byte> sequence)
{
if (sequence.IsSingleSegment)
{
// Fast path -- no copy needed
ParseSpan(sequence.FirstSpan);
}
else
{
// Multi-segment -- copy into a contiguous buffer
Span<byte> buffer = stackalloc byte[(int)sequence.Length];
sequence.CopyTo(buffer);
ParseSpan(buffer);
}
}
// TIP
When you know the maximum message size is bounded, stackalloc is a great fit for the multi-segment path. For unbounded messages, rent from ArrayPool<byte>.Shared instead.
Backpressure for free
One of the most powerful features of System.IO.Pipelines is built-in backpressure. When the reader cannot keep up with the writer, the pipe automatically slows the writer down -- no manual signalling required.
This is controlled by two PipeOptions settings:
var options = new PipeOptions(
pauseWriterThreshold: 64 * 1024, // 64 KB
resumeWriterThreshold: 32 * 1024 // 32 KB
);
var pipe = new Pipe(options);
When the unread data in the pipe exceeds PauseWriterThreshold, the next call to FlushAsync returns an incomplete ValueTask -- the writer is paused without throwing an exception. Once the reader consumes enough data to bring the buffer below ResumeWriterThreshold, the writer resumes.
Two thresholds instead of one prevents rapid oscillation between paused and resumed states. The gap between them acts as hysteresis.
// WARNING
If you never check FlushResult.IsCompleted in your writer loop, you'll miss the signal that the reader has finished. The writer will keep pumping data into a pipe nobody is reading.
Bridging streams and pipelines
You don't need a raw socket to benefit from pipelines. PipeReader and PipeWriter have static Create methods that wrap any Stream:
async Task ProcessFileAsync(string path)
{
await using var stream = File.OpenRead(path);
var reader = PipeReader.Create(stream, new StreamPipeReaderOptions(
bufferSize: 4096,
minimumReadSize: 1024
));
await ParsePipeAsync(reader);
}
This gives you the same ReadOnlySequence-based parsing, the same AdvanceTo semantics, and the same buffer pooling -- even when the underlying source is a file, an HTTP response stream, or a GZipStream.
Similarly, PipeWriter.Create wraps a writable stream:
var writer = PipeWriter.Create(outputStream, new StreamPipeWriterOptions(
minimumBufferSize: 4096
));
// TIP
Set leaveOpen: true in StreamPipeReaderOptions or StreamPipeWriterOptions if you need to keep using the stream after the pipe reader or writer completes.
Cancellation patterns
PipeReader.ReadAsync accepts a CancellationToken, but there's also a softer alternative: CancelPendingRead. Instead of throwing an OperationCanceledException, it causes the current or next ReadAsync to return a ReadResult with IsCanceled set to true. This lets you break out of the read loop gracefully:
public class ConnectionHandler(PipeReader reader)
{
public void Abort() => reader.CancelPendingRead();
public async Task RunAsync()
{
while (true)
{
ReadResult result = await reader.ReadAsync();
if (result.IsCanceled)
break;
ReadOnlySequence<byte> buffer = result.Buffer;
// ... parse messages ...
reader.AdvanceTo(buffer.Start, buffer.End);
if (result.IsCompleted)
break;
}
await reader.CompleteAsync();
}
}
The same pattern applies to the writer side via PipeWriter.CancelPendingFlush.
A practical example: length-prefixed protocol
Most real-world protocols use length-prefixed messages rather than delimiter-based framing. Here's a parser that reads a 4-byte big-endian length header followed by a payload:
async Task ProcessMessagesAsync(PipeReader reader)
{
while (true)
{
ReadResult result = await reader.ReadAsync();
ReadOnlySequence<byte> buffer = result.Buffer;
while (TryReadMessage(ref buffer, out ReadOnlySequence<byte> payload))
{
await HandleMessageAsync(payload);
}
reader.AdvanceTo(buffer.Start, buffer.End);
if (result.IsCompleted)
{
if (buffer.Length > 0)
throw new InvalidDataException("Incomplete message at end of stream.");
break;
}
}
await reader.CompleteAsync();
}
bool TryReadMessage(ref ReadOnlySequence<byte> buffer, out ReadOnlySequence<byte> payload)
{
payload = default;
if (buffer.Length < 4)
return false;
// Read the 4-byte length prefix
Span<byte> lengthBytes = stackalloc byte[4];
buffer.Slice(0, 4).CopyTo(lengthBytes);
int length = BinaryPrimitives.ReadInt32BigEndian(lengthBytes);
if (buffer.Length < 4 + length)
return false;
payload = buffer.Slice(4, length);
buffer = buffer.Slice(4 + length);
return true;
}
No buffer resizing. No manual offset tracking. The pipe handles all of it. And if a message header arrives in one read and the payload in the next, AdvanceTo with the examined position ensures ReadAsync waits for more data automatically.
Common pitfalls
Forgetting AdvanceTo
Every call to ReadAsync must be followed by a call to AdvanceTo. If you skip it, the ReadOnlySequence from the read result remains pinned in memory, and the pipe cannot reclaim those buffers. Eventually you'll run out of memory.
Passing wrong values to AdvanceTo
This is the most common source of bugs. Getting consumed and examined wrong can cause:
- Infinite loops -- if
consumednever advances past previously seen data,ReadAsyncreturns the same bytes forever. - Data loss -- if
consumedskips past unprocessed data, those bytes are gone. - Stalled reads -- if
examinedis set tobuffer.Endwhen parsing a single message, the nextReadAsyncblocks until new data arrives even if there are more messages in the buffer. - OOM exceptions -- if you never consume data but keep examining to the end, the pipe keeps accumulating buffers.
The rule of thumb: set consumed to the start of the first unprocessed byte, and set examined to buffer.End when you couldn't find a complete message.
Holding onto the buffer after AdvanceTo
The ReadOnlySequence<byte> returned by ReadAsync is only valid until the next AdvanceTo call. Storing it in a field or returning it from a method is a use-after-free bug -- the pipe may have already returned that memory to the pool.
// Bad -- use-after-free
ReadResult result = await reader.ReadAsync();
_savedBuffer = result.Buffer; // Don't do this
reader.AdvanceTo(result.Buffer.Start, result.Buffer.End);
// _savedBuffer now points to recycled memory
If you need to keep data beyond the AdvanceTo call, copy it into your own buffer first.
Checking IsCompleted before processing
A common bug is breaking out of the loop when IsCompleted is true before processing the remaining buffer. The final ReadResult can contain the last chunk of data. Always process the buffer first, then check IsCompleted:
// Bad -- loses the final chunk
if (result.IsCompleted)
break;
Process(result.Buffer);
// Good -- processes everything, then exits
Process(result.Buffer);
if (result.IsCompleted && buffer.IsEmpty)
break;
Forgetting to call CompleteAsync
Both PipeReader and PipeWriter must be completed when you're done. Failing to complete them leaks the internal buffers. Always complete in a finally block or use a pattern that guarantees completion.
When to use System.IO.Pipelines
Pipelines shine in scenarios involving:
- Custom TCP/UDP servers where you control the protocol framing.
- Protocol parsers (Redis, MQTT, custom binary protocols) that need to handle partial reads.
- File processing pipelines that parse large files in chunks without loading them entirely into memory.
- Middleware that inspects or transforms request/response bodies in ASP.NET Core.
For simple "read entire file" or "download a JSON payload" scenarios, Stream and System.Text.Json are perfectly fine. Pipelines add value when you're dealing with streaming data where messages arrive incrementally and you need precise control over buffering and memory.
Summary
System.IO.Pipelineseliminates manual buffer management by giving you a producer/consumer model built onPipeWriterandPipeReader.- The
Pipehandles memory pooling, buffer growth, and reclamation internally. - Built-in backpressure via
PauseWriterThresholdandResumeWriterThresholdprevents fast writers from overwhelming slow readers. PipeReader.CreateandPipeWriter.Createlet you wrap anyStream, so you don't need a raw socket to benefit.AdvanceTois the most critical call in the API -- getconsumedandexaminedright and everything else follows.- The
ReadOnlySequence<byte>returned byReadAsyncis only valid until the nextAdvanceTocall. Copy if you need to persist. - Always complete both the reader and writer, and always check
IsCompletedafter processing the buffer, not before.