IBufferWriter<T> and Writers
Protocol for writing to growable or fixed buffers without copying
IBufferWriter and Writers
Master high-performance buffer writing in .NET with free flashcards and spaced repetition practice. This lesson covers IBufferWriter<T>, ArrayBufferWriter<T>, PipeWriter, and custom writer implementationsβessential concepts for building zero-allocation, high-throughput systems that minimize memory pressure and maximize performance.
Welcome π»
When building high-performance .NET applications, controlling how data flows into memory buffers is just as critical as managing the buffers themselves. The IBufferWriter<T> abstraction provides a standardized way to write data incrementally without repeatedly allocating new arrays or creating intermediate copies. This pattern is foundational in modern .NET APIs like System.IO.Pipelines, ASP.NET Core, and serialization libraries.
Unlike traditional approaches where you allocate a buffer, fill it, then potentially resize and copyβIBufferWriter<T> lets the buffer provider decide how to allocate memory, enabling pooling, reuse, and batching strategies that dramatically reduce GC pressure. You'll discover how writers decouple the logic of "what to write" from "where to write it," making your code more testable, flexible, and performant.
Core Concepts π
What is IBufferWriter?
IBufferWriter<T> is an interface that represents a destination for sequential writing of binary or structured data. It provides a contract between code that produces data and the underlying buffer management strategy:
public interface IBufferWriter{ void Advance(int count); Memory GetMemory(int sizeHint = 0); Span GetSpan(int sizeHint = 0); }
The workflow is simple but powerful:
βββββββββββββββββββββββββββββββββββββββββββββββ β IBufferWriterWRITE CYCLE β βββββββββββββββββββββββββββββββββββββββββββββββ 1οΈβ£ Request buffer space | β π¦ GetSpan(sizeHint) π¦ GetMemory(sizeHint) | β 2οΈβ£ Write data to buffer | β 3οΈβ£ Notify bytes written | β β Advance(count) | β π Repeat as needed
Key characteristics:
- No allocations required by caller - the writer provides the buffer
- Flexible sizing -
sizeHintguides allocation but doesn't guarantee exact size - Commit pattern -
Advance()confirms how much was actually written - Zero-copy design - work directly with
Span<T>orMemory<T>
The Three Core Methods
GetSpan(int sizeHint = 0)
Returns a Span<T> to write into. This is the synchronous, stack-only optionβperfect for performance-critical paths where you're writing immediately:
Span<byte> buffer = writer.GetSpan(256);
int bytesWritten = Encoding.UTF8.GetBytes("Hello", buffer);
writer.Advance(bytesWritten);
π‘ Tip: Use GetSpan() when you're writing synchronously and don't need to pass the buffer to async methods.
GetMemory(int sizeHint = 0)
Returns a Memory<T> to write into. This is the async-friendly option that can be stored and passed across await boundaries:
Memory<byte> buffer = writer.GetMemory(1024);
int bytesRead = await stream.ReadAsync(buffer);
writer.Advance(bytesRead);
Advance(int count)
Commits the number of elements actually written. This must be called after writing to notify the buffer writer:
// β WRONG: Forgot to call Advance
Span<byte> buffer = writer.GetSpan(10);
buffer[0] = 0xFF;
// Writer doesn't know anything was written!
// β
RIGHT: Always advance
Span<byte> buffer = writer.GetSpan(10);
buffer[0] = 0xFF;
writer.Advance(1);
β οΈ Critical Rule: Never call Advance() with a count larger than the buffer size you received, and never write beyond the buffer boundaries!
ArrayBufferWriter - The Standard Implementation
ArrayBufferWriter<T> is the built-in, general-purpose implementation that uses a growable array with automatic resizing:
var writer = new ArrayBufferWriter<byte>();
// Write some data
Span<byte> buffer = writer.GetSpan(5);
"Hello".AsSpan().CopyTo(MemoryMarshal.Cast<byte, char>(buffer));
writer.Advance(5);
// Access written data
ReadOnlySpan<byte> written = writer.WrittenSpan;
ReadOnlyMemory<byte> memory = writer.WrittenMemory;
// Reset for reuse
writer.Clear();
Properties you need to know:
| Property | Purpose | Type |
|---|---|---|
WrittenCount | Total bytes written so far | int |
WrittenSpan | View of written data as Span | ReadOnlySpan<T> |
WrittenMemory | View of written data as Memory | ReadOnlyMemory<T> |
Capacity | Current buffer capacity | int |
FreeCapacity | Available space before resize | int |
Growth strategy:
Initial: 256 bytes (default) | β (needs more) Resize: 512 bytes (2x) | β (needs more) Resize: 1024 bytes (2x) | β continues doubling...
π§ Memory Device: Think of ArrayBufferWriter<T> as a smart StringBuilder for bytesβit grows automatically and lets you write incrementally without managing the array yourself.
PipeWriter - High-Performance Streaming
PipeWriter is part of System.IO.Pipelines and provides backpressure-aware, high-throughput writing for streaming scenarios:
public async Task WriteDataAsync(PipeWriter writer)
{
for (int i = 0; i < 1000; i++)
{
// Get buffer from pipe
Memory<byte> buffer = writer.GetMemory(4);
BinaryPrimitives.WriteInt32LittleEndian(buffer.Span, i);
writer.Advance(4);
// Flush periodically
if (i % 100 == 0)
{
FlushResult result = await writer.FlushAsync();
if (result.IsCompleted)
break; // Reader signaled completion
}
}
await writer.CompleteAsync();
}
Key differences from ArrayBufferWriter:
| Feature | ArrayBufferWriter | PipeWriter |
|---|---|---|
| Use Case | In-memory buffering | Streaming/networking |
| Backpressure | None (grows indefinitely) | Built-in flow control |
| Flushing | N/A | FlushAsync() required |
| Completion | N/A | CompleteAsync() signals end |
| Threading | Not thread-safe | Single writer enforced |
π‘ Tip: Use PipeWriter when writing to sockets, files, or any scenario where the consumer might be slower than the producer.
Real-World Examples π οΈ
Example 1: Writing a Custom Protocol Message
Let's build a binary message writer for a custom protocol:
public class ProtocolMessageWriter
{
private readonly IBufferWriter<byte> _writer;
public ProtocolMessageWriter(IBufferWriter<byte> writer)
{
_writer = writer;
}
public void WriteMessage(int messageId, ReadOnlySpan<byte> payload)
{
// Calculate total size: 4 bytes (ID) + 4 bytes (length) + payload
int totalSize = 8 + payload.Length;
// Request buffer space
Span<byte> buffer = _writer.GetSpan(totalSize);
// Write message ID (4 bytes, little-endian)
BinaryPrimitives.WriteInt32LittleEndian(buffer, messageId);
// Write payload length (4 bytes, little-endian)
BinaryPrimitives.WriteInt32LittleEndian(buffer.Slice(4), payload.Length);
// Copy payload
payload.CopyTo(buffer.Slice(8));
// Commit the write
_writer.Advance(totalSize);
}
}
// Usage:
var arrayWriter = new ArrayBufferWriter<byte>();
var protocol = new ProtocolMessageWriter(arrayWriter);
protocol.WriteMessage(42, "Hello"u8);
protocol.WriteMessage(43, "World"u8);
byte[] result = arrayWriter.WrittenSpan.ToArray();
Why this works well:
- Single allocation for the entire buffer
- No intermediate byte arrays
- Works with any
IBufferWriter<byte>implementation - Easy to test with different writers
Example 2: Custom Pooled BufferWriter
Create a writer that uses ArrayPool<T> for memory efficiency:
public sealed class PooledBufferWriter<T> : IBufferWriter<T>, IDisposable
{
private T[] _buffer;
private int _written;
private readonly ArrayPool<T> _pool;
public PooledBufferWriter(int initialCapacity = 256, ArrayPool<T>? pool = null)
{
_pool = pool ?? ArrayPool<T>.Shared;
_buffer = _pool.Rent(initialCapacity);
_written = 0;
}
public int WrittenCount => _written;
public ReadOnlySpan<T> WrittenSpan => _buffer.AsSpan(0, _written);
public void Advance(int count)
{
if (count < 0 || _written + count > _buffer.Length)
throw new ArgumentException("Invalid advance count");
_written += count;
}
public Memory<T> GetMemory(int sizeHint = 0)
{
EnsureCapacity(sizeHint);
return _buffer.AsMemory(_written);
}
public Span<T> GetSpan(int sizeHint = 0)
{
EnsureCapacity(sizeHint);
return _buffer.AsSpan(_written);
}
private void EnsureCapacity(int sizeHint)
{
int available = _buffer.Length - _written;
if (available < sizeHint)
{
int newSize = Math.Max(_buffer.Length * 2, _written + sizeHint);
T[] newBuffer = _pool.Rent(newSize);
_buffer.AsSpan(0, _written).CopyTo(newBuffer);
_pool.Return(_buffer);
_buffer = newBuffer;
}
}
public void Dispose()
{
if (_buffer != null)
{
_pool.Return(_buffer);
_buffer = null!;
}
}
}
// Usage:
using var writer = new PooledBufferWriter<byte>();
Span<byte> span = writer.GetSpan(100);
// ... write data ...
writer.Advance(100);
// Buffer automatically returned to pool on dispose
Benefits:
- Zero heap allocations for the buffer
- Automatic pooling and return
- Drop-in replacement for
ArrayBufferWriter<T>
Example 3: JSON Writing Without Utf8JsonWriter
Write JSON manually using IBufferWriter<byte> for learning purposes:
public static class SimpleJsonWriter
{
public static void WriteObject(IBufferWriter<byte> writer,
Dictionary<string, object> properties)
{
WriteUtf8(writer, "{");
bool first = true;
foreach (var kvp in properties)
{
if (!first) WriteUtf8(writer, ",");
first = false;
// Write key
WriteUtf8(writer, "\"");
WriteUtf8(writer, kvp.Key);
WriteUtf8(writer, "\":");
// Write value
switch (kvp.Value)
{
case string s:
WriteUtf8(writer, "\"");
WriteUtf8(writer, s);
WriteUtf8(writer, "\"");
break;
case int i:
WriteUtf8(writer, i.ToString());
break;
case bool b:
WriteUtf8(writer, b ? "true" : "false");
break;
}
}
WriteUtf8(writer, "}");
}
private static void WriteUtf8(IBufferWriter<byte> writer, string text)
{
int maxBytes = Encoding.UTF8.GetMaxByteCount(text.Length);
Span<byte> buffer = writer.GetSpan(maxBytes);
int bytesWritten = Encoding.UTF8.GetBytes(text, buffer);
writer.Advance(bytesWritten);
}
}
// Usage:
var writer = new ArrayBufferWriter<byte>();
var data = new Dictionary<string, object>
{
["name"] = "Alice",
["age"] = 30,
["active"] = true
};
SimpleJsonWriter.WriteObject(writer, data);
string json = Encoding.UTF8.GetString(writer.WrittenSpan);
// Result: {"name":"Alice","age":30,"active":true}
Example 4: Batching Writes with Size Limits
Implement a writer that flushes automatically when reaching a size threshold:
public class BatchingWriter<T> : IBufferWriter<T>
{
private readonly IBufferWriter<T> _innerWriter;
private readonly Action<ReadOnlySpan<T>> _onBatch;
private readonly int _batchSize;
private int _currentBatchCount;
public BatchingWriter(IBufferWriter<T> innerWriter,
int batchSize,
Action<ReadOnlySpan<T>> onBatch)
{
_innerWriter = innerWriter;
_batchSize = batchSize;
_onBatch = onBatch;
_currentBatchCount = 0;
}
public void Advance(int count)
{
_innerWriter.Advance(count);
_currentBatchCount += count;
if (_currentBatchCount >= _batchSize)
{
Flush();
}
}
public Memory<T> GetMemory(int sizeHint = 0) =>
_innerWriter.GetMemory(sizeHint);
public Span<T> GetSpan(int sizeHint = 0) =>
_innerWriter.GetSpan(sizeHint);
public void Flush()
{
if (_currentBatchCount > 0)
{
if (_innerWriter is ArrayBufferWriter<T> abw)
{
_onBatch(abw.WrittenSpan);
abw.Clear();
_currentBatchCount = 0;
}
}
}
}
// Usage:
var baseWriter = new ArrayBufferWriter<byte>();
int batchCount = 0;
var batchWriter = new BatchingWriter<byte>(
baseWriter,
batchSize: 1024,
onBatch: batch =>
{
Console.WriteLine($"Batch {++batchCount}: {batch.Length} bytes");
// Process batch (write to file, send over network, etc.)
}
);
for (int i = 0; i < 10000; i++)
{
Span<byte> buffer = batchWriter.GetSpan(4);
BinaryPrimitives.WriteInt32LittleEndian(buffer, i);
batchWriter.Advance(4);
}
batchWriter.Flush(); // Flush remaining data
Use cases:
- Writing to files in optimal chunks
- Batching network sends
- Database bulk inserts
- Logging aggregation
Common Mistakes β οΈ
Mistake 1: Forgetting to Call Advance()
β WRONG:
Span<byte> buffer = writer.GetSpan(10);
Encoding.UTF8.GetBytes("test", buffer);
// Forgot Advance() - writer doesn't know anything was written!
β
RIGHT:
Span<byte> buffer = writer.GetSpan(10);
int written = Encoding.UTF8.GetBytes("test", buffer);
writer.Advance(written); // Always commit!
Mistake 2: Advancing More Than Written
β WRONG:
Span<byte> buffer = writer.GetSpan(100);
int written = SomeMethod(buffer); // Returns 50
writer.Advance(100); // Advanced more than actually written!
β
RIGHT:
Span<byte> buffer = writer.GetSpan(100);
int written = SomeMethod(buffer);
writer.Advance(written); // Advance exactly what was written
Mistake 3: Holding References Across Calls
β WRONG:
Span<byte> buffer1 = writer.GetSpan(10);
Span<byte> buffer2 = writer.GetSpan(10); // buffer1 may be invalidated!
buffer1[0] = 1; // DANGER: buffer1 might point to wrong memory
β
RIGHT:
Span<byte> buffer = writer.GetSpan(10);
buffer[0] = 1;
writer.Advance(1);
// Get new buffer for next write
buffer = writer.GetSpan(10);
buffer[0] = 2;
writer.Advance(1);
π§ Remember: Treat buffers from GetSpan()/GetMemory() as transientβthey're only valid until the next call to those methods or Advance().
Mistake 4: Ignoring sizeHint Semantics
β WRONG:
Span<byte> buffer = writer.GetSpan(1000);
if (buffer.Length < 1000)
throw new Exception("Buffer too small!"); // sizeHint is not a guarantee!
β
RIGHT:
Span<byte> buffer = writer.GetSpan(1000);
int available = buffer.Length; // Use what you get
int toWrite = Math.Min(available, dataToWrite.Length);
dataToWrite.Slice(0, toWrite).CopyTo(buffer);
writer.Advance(toWrite);
Mistake 5: Not Disposing PooledBufferWriter
β WRONG:
var writer = new PooledBufferWriter<byte>();
// ... use writer ...
// Forgot to dispose - buffer leaked from pool!
β
RIGHT:
using var writer = new PooledBufferWriter<byte>();
// ... use writer ...
// Automatically returned to pool
Mistake 6: Concurrent Writes
β WRONG:
var writer = new ArrayBufferWriter<byte>();
Parallel.For(0, 100, i =>
{
Span<byte> buffer = writer.GetSpan(4); // NOT THREAD-SAFE!
BinaryPrimitives.WriteInt32LittleEndian(buffer, i);
writer.Advance(4);
});
β
RIGHT:
var writer = new ArrayBufferWriter<byte>();
lock (writer) // Or use concurrent collection
{
for (int i = 0; i < 100; i++)
{
Span<byte> buffer = writer.GetSpan(4);
BinaryPrimitives.WriteInt32LittleEndian(buffer, i);
writer.Advance(4);
}
}
β οΈ Important: Neither ArrayBufferWriter<T> nor PipeWriter is thread-safe. Synchronize access or use separate writers per thread.
Key Takeaways π―
IBufferWriter<T>decouples data production from buffer management, enabling flexible, testable, high-performance code.Always call
Advance(count)after writingβthis commits your data and tells the writer how much you actually wrote.GetSpan()for sync,GetMemory()for asyncβchoose based on whether you need to cross await boundaries.ArrayBufferWriter<T>is great for in-memory scenarios where you need to accumulate data before processing it.PipeWriterexcels at streaming with built-in backpressure, making it ideal for network protocols and file I/O.sizeHintis a suggestion, not a guaranteeβalways check the actual buffer length returned.Buffers are transientβdon't hold references across multiple
GetSpan()/GetMemory()calls.Pool-based writers minimize allocations by reusing buffers from
ArrayPool<T>.Thread safety requires external synchronizationβstandard implementations aren't thread-safe.
Compose writers for powerful patterns like batching, compression, encryption, or protocol framing.
π Quick Reference Card
| Concept | Key Point |
|---|---|
| GetSpan() | Returns Span<T> for synchronous writing |
| GetMemory() | Returns Memory<T> for async-friendly writing |
| Advance(count) | Commits count elements as written (required!) |
| ArrayBufferWriter | Built-in growable array implementation |
| PipeWriter | High-throughput streaming with backpressure |
| sizeHint | Suggests desired size, not guaranteed |
| WrittenSpan | View of all data written so far |
| FlushAsync() | Sends buffered data (PipeWriter only) |
| CompleteAsync() | Signals end of writing (PipeWriter only) |
| Thread Safety | Noneβsynchronize externally if needed |
π Further Study
- Microsoft Docs - IBufferWriter
: https://learn.microsoft.com/en-us/dotnet/api/system.buffers.ibufferwriter-1 - System.IO.Pipelines Overview: https://learn.microsoft.com/en-us/dotnet/standard/io/pipelines
- High-Performance Buffer Management: https://learn.microsoft.com/en-us/dotnet/standard/memory-and-spans/