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:

Services/NaiveLineReader.cs
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:

  1. A single ReadAsync call might not return a complete line.
  2. It might return multiple lines in one go.
  3. A line longer than 1,024 bytes silently truncates.
  4. 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:

Example.cs
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.

Services/PipelineServer.cs
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:

Services/PipelineServer.cs
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:

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:

Services/PipelineServer.cs
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:

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:

Services/MessageParser.cs
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:

Services/PipelineServer.cs
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:

Services/FileProcessor.cs
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:

Services/FileProcessor.cs
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:

Services/ConnectionHandler.cs
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:

Services/LengthPrefixedParser.cs
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:

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.

Example.cs
// 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:

Example.cs
// 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:

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