BrandGhost
Pipeline Pattern vs System.IO.Pipelines in .NET

Pipeline Pattern vs System.IO.Pipelines in .NET

The pipeline pattern vs System.IO.Pipelines comparison starts with an overloaded word. The architectural Pipeline Pattern describes processing as ordered stages with declared boundaries. System.IO.Pipelines is a specialized .NET API for moving and parsing bytes. They can appear in the same system, but they solve different problems and carry very different ownership rules.

This naming collision matters because the wrong mental model produces the wrong API choice. A business pipeline might validate an order, calculate a price, and create an invoice. An I/O pipe might receive a length-prefixed frame across a socket and expose its bytes to a parser. Calling both of them "pipelines" does not make their contracts interchangeable.

This article stays on that boundary. It explains PipeReader, PipeWriter, ReadOnlySequence<byte>, buffer lifetime, flow control, flush results, final-buffer handling, and completion. It does not build a generic business-stage framework.

Pipeline Pattern vs System.IO.Pipelines Starts With the Data Unit

Microsoft's Pipes and Filters pattern describes independent filters connected by pipes. A filter understands its input and output schema. That architectural idea is intentionally broad: the schema could represent commands, documents, domain records, images, or any other application contract.

The System.IO.Pipelines documentation describes something narrower. The library was designed to make high-performance streaming I/O and parsing easier. Its core data unit is byte, its writer implements IBufferWriter<byte>, and its reader returns buffered bytes as ReadOnlySequence<byte>.

That gives us a practical distinction:

Question Architectural Pipeline Pattern System.IO.Pipelines
What flows? Application-defined stage contracts Bytes
What is composed? Transformations or processing stages A byte producer and byte consumer
Primary boundary TInput to TOutput or a terminal outcome PipeWriter to PipeReader
Backpressure unit Depends on the implementation Buffered byte thresholds
Typical use Business processing, transformations, validation Protocol parsing, sockets, serializers, streaming I/O
Main correctness concern Stage order, contracts, errors, ownership Buffer lifetime, advancement, flushing, completion

System.IO.Pipelines can sit inside one architectural stage. For example, a network-ingress stage might parse frames with a PipeReader, create typed messages, and pass those messages into a separate business pipeline. The I/O API handles bytes. The architectural pipeline handles meaning.

PipeReader, PipeWriter, and ReadOnlySequence

A Pipe creates a connected pair:

  • The PipeWriter owns writes into pipe-managed memory.
  • The PipeReader owns reads from that buffered byte stream.
  • Bytes advanced and flushed by the writer become available to the reader.

The writer usually requests memory with GetMemory or GetSpan, copies or encodes data into that memory, calls Advance with the number of bytes written, and then awaits FlushAsync, following the official writer guidance. Asking for 100 bytes means "at least 100 bytes," not "exactly 100 bytes." The writer must stop using a returned buffer after advancing and request another buffer for the next write, as required by the pipe buffer lifetime rules.

The reader awaits ReadAsync and receives a ReadResult whose Buffer property is a ReadOnlySequence<byte>, not necessarily one contiguous array. That sequence can describe one segment or several pooled segments as a single logical range. A parser should therefore use sequence-aware operations instead of assuming that a complete header or payload lives in one array.

The reader also receives lifecycle information:

That last point is easy to miss. Completion says no more data is coming. It does not say the final buffer is empty.

A Complete Length-Prefixed Pipe Example

The following example targets the cluster baseline directly. System.IO.Pipelines is available in the .NET 10 shared framework, so this console project has no external package reference.

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net10.0</TargetFramework>
    <LangVersion>14.0</LangVersion>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
    <TreatWarningsAsErrors>true</TreatWarningsAsErrors>
  </PropertyGroup>
</Project>

Each frame contains a four-byte big-endian payload length followed by a UTF-8 payload. One task exclusively owns the writer. One task exclusively owns the reader. Both tasks complete their side in finally, and the coordinator observes both tasks.

using System.Buffers;
using System.Buffers.Binary;
using System.IO.Pipelines;
using System.Text;

namespace PipelineVsIoPipelines;

public static class Program
{
    private const int HeaderLength = sizeof(int);
    private const int MaximumPayloadLength = 64 * 1024;
    private const long PauseWriterThreshold = 128 * 1024;
    private const long ResumeWriterThreshold = 64 * 1024;

    public static async Task Main()
    {
        string[] messages =
        [
            "first frame",
            "second frame",
            "final frame"
        ];

        await RunPipeAsync(messages, CancellationToken.None);
    }

    private static async Task RunPipeAsync(
        IEnumerable<string> messages,
        CancellationToken cancellationToken)
    {
        var pipe = new Pipe(
            new PipeOptions(
                pauseWriterThreshold: PauseWriterThreshold,
                resumeWriterThreshold: ResumeWriterThreshold,
                useSynchronizationContext: false));

        using CancellationTokenSource linkedCancellation =
            CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);

        Task readerTask = ReadFramesAsync(
            pipe.Reader,
            linkedCancellation.Token);
        Task writerTask = WriteFramesAsync(
            pipe.Writer,
            messages,
            linkedCancellation.Token);

        Task firstFinished = await Task.WhenAny(readerTask, writerTask);
        if (!firstFinished.IsCompletedSuccessfully)
        {
            await linkedCancellation.CancelAsync();
        }

        await Task.WhenAll(readerTask, writerTask);
    }

    private static async Task WriteFramesAsync(
        PipeWriter writer,
        IEnumerable<string> messages,
        CancellationToken cancellationToken)
    {
        Exception? completionError = null;

        try
        {
            foreach (string message in messages)
            {
                byte[] payload = Encoding.UTF8.GetBytes(message);
                if (payload.Length > MaximumPayloadLength)
                {
                    throw new InvalidDataException(
                        $"Payload exceeds {MaximumPayloadLength} bytes.");
                }

                int frameLength = HeaderLength + payload.Length;
                Memory<byte> memory = writer.GetMemory(frameLength);

                BinaryPrimitives.WriteInt32BigEndian(
                    memory.Span[..HeaderLength],
                    payload.Length);
                payload.AsSpan().CopyTo(
                    memory.Span.Slice(HeaderLength, payload.Length));

                writer.Advance(frameLength);

                FlushResult flushResult =
                    await writer.FlushAsync(cancellationToken);

                if (flushResult.IsCanceled)
                {
                    throw new OperationCanceledException(
                        "The pending pipe flush was canceled.",
                        cancellationToken);
                }

                if (flushResult.IsCompleted)
                {
                    return;
                }
            }
        }
        catch (Exception ex)
        {
            completionError = ex;
            throw;
        }
        finally
        {
            await writer.CompleteAsync(completionError);
        }
    }

    private static async Task ReadFramesAsync(
        PipeReader reader,
        CancellationToken cancellationToken)
    {
        Exception? completionError = null;

        try
        {
            while (true)
            {
                ReadResult readResult =
                    await reader.ReadAsync(cancellationToken);
                ReadOnlySequence<byte> buffer = readResult.Buffer;
                SequencePosition consumed = buffer.Start;
                SequencePosition examined = buffer.End;

                try
                {
                    if (readResult.IsCanceled)
                    {
                        throw new OperationCanceledException(
                            "The pending pipe read was canceled.",
                            cancellationToken);
                    }

                    while (TryReadFrame(ref buffer, out string message))
                    {
                        Console.WriteLine(message);
                        consumed = buffer.Start;
                    }

                    if (readResult.IsCompleted)
                    {
                        if (!buffer.IsEmpty)
                        {
                            throw new InvalidDataException(
                                "The final pipe buffer contains an incomplete frame.");
                        }

                        break;
                    }
                }
                finally
                {
                    reader.AdvanceTo(consumed, examined);
                }
            }
        }
        catch (Exception ex)
        {
            completionError = ex;
            throw;
        }
        finally
        {
            await reader.CompleteAsync(completionError);
        }
    }

    private static bool TryReadFrame(
        ref ReadOnlySequence<byte> buffer,
        out string message)
    {
        if (buffer.Length < HeaderLength)
        {
            message = string.Empty;
            return false;
        }

        Span<byte> header = stackalloc byte[HeaderLength];
        buffer.Slice(0, HeaderLength).CopyTo(header);

        int payloadLength = BinaryPrimitives.ReadInt32BigEndian(header);
        if (payloadLength < 0 || payloadLength > MaximumPayloadLength)
        {
            throw new InvalidDataException(
                $"Invalid payload length: {payloadLength}.");
        }

        long frameLength = HeaderLength + (long)payloadLength;
        if (buffer.Length < frameLength)
        {
            message = string.Empty;
            return false;
        }

        ReadOnlySequence<byte> payload =
            buffer.Slice(HeaderLength, payloadLength);
        message = Encoding.UTF8.GetString(payload.ToArray());
        buffer = buffer.Slice(frameLength);
        return true;
    }
}

The locally executed .NET 10 validation built this exact sample and produced all three frames, including the frame delivered with the writer's completion signal:

first frame
second frame
final frame

The example copies each decoded payload into a string before advancing the reader. That is a deliberate ownership transfer. Under the documented buffer-lifetime rule, returning a ReadOnlySequence<byte> slice to unrelated code and then calling AdvanceTo would leave that code holding pipe-managed memory whose lifetime has ended.

The maximum payload length is also part of correctness. A peer can announce a length without sending the corresponding body. Without a protocol limit, a parser might retain an ever-growing incomplete frame. The appropriate limit belongs to the protocol contract, not to System.IO.Pipelines itself.

Follow One Frame Through the Example

Walking one message from producer to parser makes the ownership changes concrete. Suppose the writer receives "first frame".

First, Encoding.UTF8.GetBytes creates the payload bytes. The writer requests enough pipe memory for the four-byte header and that payload. It writes the length in big-endian order, copies the payload, and calls Advance(frameLength). Until Advance runs, the pipe does not know how much of the requested memory contains valid data.

The writer then awaits FlushAsync. This is the publication boundary for the reader. The flush might finish immediately, or it might wait because the reader has not released enough buffered bytes. Either way, the writer does not request another buffer while that flush remains incomplete.

On the reader side, ReadAsync returns the bytes available for that read. There is no promise that the four-byte header and payload occupy one segment. TryReadFrame first checks the logical sequence length. It then copies only the fixed-size header to stack memory so BinaryPrimitives.ReadInt32BigEndian can read it safely.

After validating the announced payload length, the parser checks whether the full frame is present. If not, it returns false without consuming the partial frame. Following the consumed and examined contract, the read loop marks those bytes as examined, retains them as unconsumed, and waits for another read.

When the full frame is available, the parser slices the payload, converts it to an independently owned string, and slices the local sequence past the frame. The read loop records that new start as consumed. Only then can AdvanceTo tell the pipe that the frame's backing memory is eligible for reuse.

This flow explains why a parser should not return a borrowed sequence casually. The parser can process a payload synchronously before advancement, or it can copy/materialize an owned result for later work. What it cannot safely do is keep using pipe memory after it has told the reader that the memory was consumed.

ReadOnlySequence Changes Parser Design

Code written for byte[] often starts with array indexing. A pipe parser needs a sequence-first design because the current data may span segments.

Fixed-size fields can be copied into a small stack buffer, as the example does for the four-byte length. Delimiter-based protocols can use operations such as PositionOf and slice around the returned SequencePosition. Larger payloads can be processed segment by segment when the downstream API accepts spans or sequences.

Calling ToArray is valid when the parser intentionally needs owned contiguous data, but it allocates and copies. The example accepts that cost at the boundary because it produces a normal string that survives AdvanceTo. A different parser might deserialize directly from sequence segments or copy into another owned pooled buffer. That is an implementation choice, not a universal performance rule.

The important decision is explicit ownership. If downstream work outlives the current read iteration, give it memory whose lifetime it owns. If processing remains inside the read loop, keep the borrowed sequence scoped to the period before AdvanceTo.

AdvanceTo Is a Buffer-Lifetime Boundary

Every successful ReadAsync must be paired with AdvanceTo. The example places that call in finally so parsing exceptions, cancellation paths, and normal completion all release or retain memory deliberately.

The two positions have different meanings:

  • The consumed position identifies bytes the reader has finished using, so the pipe may reclaim that memory.
  • The examined position identifies bytes the parser inspected while looking for more complete data.

In the example, TryReadFrame slices complete frames from the local buffer. After each frame, consumed moves to the start of the remaining bytes. examined remains at the end of the original ReadResult.Buffer because the parser examined all available data while looking for another complete frame.

If only part of a frame is present, the reader consumes none of that partial frame but marks the available bytes as examined. Under the documented consumed and examined semantics, the next ReadAsync can then wait for additional bytes rather than immediately returning the same unchanged data.

After AdvanceTo, neither the original buffer nor slices from it are valid for later access. This is not a style preference. It is the boundary at which pipe-managed storage can be returned to a pool and reused.

Final-Buffer Processing Prevents Silent Data Loss

A common broken loop checks readResult.IsCompleted before parsing readResult.Buffer. The official final-buffer guidance requires processing that buffer first because the final read can contain both the last bytes and the completion signal.

The safe order is:

  1. Parse every complete frame available in the ReadResult.
  2. If IsCompleted is false, advance and read again.
  3. If IsCompleted is true and no bytes remain, finish normally.
  4. If IsCompleted is true and an incomplete frame remains, report a protocol error.

The sample follows that order. Its final "final frame" message is processed even when it arrives in the same ReadResult that reports completion. A truncated header or payload becomes InvalidDataException instead of disappearing silently.

Flush Results Are Lifecycle Signals

Calling Advance tells the PipeWriter how many bytes were written into its current memory. Calling FlushAsync makes those bytes available to the reader and participates in the pipe's flow-control mechanism.

The returned FlushResult is not ceremonial:

  • FlushResult.IsCompleted means the reader has completed and no longer wants additional data, so the writer should stop.
  • FlushResult.IsCanceled reports a pending flush canceled through CancelPendingFlush.
  • A canceled token passed to FlushAsync can instead produce OperationCanceledException.

The example inspects both result flags and passes the caller's token to every pending read and flush. It also flushes after every advanced frame. That keeps the completion path away from the documented danger of completing a writer while unflushed data remains.

Real protocols may batch several small frames before flushing. That is a policy decision requiring measurement and a latency budget. This article makes no throughput claim about per-frame flushing.

Byte Flow Control Is Not Semantic Item Capacity

PipeOptions.PauseWriterThreshold and ResumeWriterThreshold are measured in buffered bytes. When buffered data reaches the pause threshold, FlushAsync can remain incomplete until the reader has consumed enough data to move below the lower resume threshold.

That behavior prevents the writer from running indefinitely ahead of the reader, but it does not understand messages. This sample sets the 128 KiB pause threshold above HeaderLength + MaximumPayloadLength and uses a lower 64 KiB resume threshold, so an incrementally flushing producer can make one valid 64 KiB frame available before flow control requires consumption. The pipe sees bytes, not business importance, and the 64 KiB protocol limit remains enforced independently.

This is different from a bounded item queue where capacity might mean "at most 100 jobs." Byte thresholds answer a memory-flow question. Semantic item capacity answers an application-admission question. Choosing one does not automatically solve the other.

One Owner Per Reader and Writer

The official System.IO.Pipelines guidance states that PipeReader and PipeWriter are not thread-safe. "One owner" means one coordinated execution context controls each side for its entire lifetime.

The sample gives the writer only to WriteFramesAsync and the reader only to ReadFramesAsync. It does not let several producer tasks call GetMemory, Advance, and FlushAsync concurrently. It does not let several parser tasks race over ReadAsync and AdvanceTo.

Concurrency can happen around a pipe, but it must not violate those ownership contracts. A single parser can copy or materialize complete messages and then hand those independent values to downstream workers. The pooled pipe buffer itself stays under reader ownership until AdvanceTo.

The sample also calls PipeWriter.CompleteAsync and PipeReader.CompleteAsync in finally. Passing the captured exception communicates abnormal termination and releases pipe resources. The coordinator awaits both tasks, and it requests sibling cancellation if either side faults or is canceled. No reader or writer task is left unobserved.

The PipeWriter.OnReaderCompleted and PipeReader.OnWriterCompleted callbacks are obsolete and do not belong in new code. Use the reader and writer completion methods and observe the tasks that own each loop.

When System.IO.Pipelines Is the Right Boundary

System.IO.Pipelines is a strong fit when the hard part is incremental byte handling:

  • A network protocol can split one frame across reads or combine many frames in one read.
  • A parser must inspect a logical sequence that may span pooled memory segments.
  • A serializer or protocol adapter needs direct access to reusable byte buffers.
  • A socket loop needs byte-based flow control between receiving and parsing.

If the bytes originate from HTTP, my HttpClient streaming guide covers ResponseHeadersRead, ReadAsStreamAsync, and related stream-oriented choices. A PipeReader can be created around a Stream when its parsing model is useful, but the underlying stream's ownership must be configured deliberately.

Choose a different abstraction when the items are already meaningful application values. Orders, commands, images, and documents do not become better contracts merely because they can be serialized into bytes. Keep protocol parsing at the edge, create owned typed values, and let the rest of the application work at the level of meaning.

Before selecting the API, write down the boundary in one sentence. If the sentence says "receive bytes until a complete frame can be parsed," System.IO.Pipelines is a plausible fit. If it says "run this order through validation, pricing, and persistence," use application-level stage contracts. If it says "buffer typed jobs while consumers catch up," use an item-oriented producer/consumer abstraction. This simple test prevents a specialized byte API from leaking into code that should express domain meaning.

Also decide where copying is acceptable. Parsing directly from pipe buffers keeps ownership narrow but requires sequence-aware code. Materializing a typed result introduces a copy or allocation, yet gives downstream work a normal lifetime. Neither choice is automatically superior. The correct boundary depends on the protocol, payload size, downstream API, and measured constraints.

Frequently Asked Questions About Pipeline Pattern vs System.IO.Pipelines

Is System.IO.Pipelines an implementation of the Pipeline Pattern?

Not by itself. It provides a byte producer/consumer boundary with buffer management and flow control. You can use it inside an architectural pipeline, but it does not define arbitrary business stages or typed transformations between them.

Why does PipeReader return ReadOnlySequence instead of byte[]?

Pipe-managed data can span multiple memory segments. ReadOnlySequence<byte> lets a parser treat those segments as one logical range without first requiring one contiguous array.

Must AdvanceTo run when parsing throws?

Yes. Every completed ReadAsync needs a corresponding AdvanceTo. A finally block makes the lifetime rule explicit even when parsing, cancellation, or protocol validation throws.

Can several tasks share one PipeWriter?

No. The official guidance requires one owning context for each PipeWriter and PipeReader because they are not thread-safe. Coordinate many producers before the writer boundary or serialize their access through a separate owner.

Does IsCompleted mean the current buffer is empty?

No. ReadResult.IsCompleted means no more data will arrive, while the current ReadResult.Buffer may contain final complete messages or an incomplete final message that must be processed before the loop exits.

Are pause and resume thresholds message limits?

No. PauseWriterThreshold and ResumeWriterThreshold are byte-buffer thresholds used by FlushAsync flow control. They do not count domain messages, assign priorities, or provide durable queue capacity.

Keep the Two Meanings Separate

The Pipeline Pattern is an architectural composition model. System.IO.Pipelines is a byte-oriented I/O API with strict buffer and lifecycle contracts. The names overlap, but the decision boundary is clear once you ask what data flows and who owns it.

Use PipeReader and PipeWriter for incremental protocols, parsers, sockets, serializers, and other byte-stream edges. Process the final buffer, advance every read, inspect flush results, respect byte flow control, give each side one owner, and complete both sides. Once bytes become owned application values, move back to application-level abstractions instead of carrying pipe-managed memory into business processing.

Async Pipelines in C#: Await, Cancellation, and Completion

Build correct async pipelines in C# with sequential awaits, propagated cancellation, observed tasks, explicit completion ownership, and safe async disposal.

How To Implement The Pipeline Design Pattern in C#

Learn about the pipeline design pattern in C#. Discover how to create and chain pipeline stages. Get code examples, tips, and use cases for this design pattern.

Pipeline Pattern in C#: A Modern .NET Guide

Learn the pipeline pattern in C#, its core stages, execution models, tradeoffs, and how to choose a stable .NET 10 implementation for production systems.

An error has occurred. This application may no longer respond until reloaded. Reload