.NET 11's Stream Adapters: Stop Copying Buffers Around
Every .NET developer has the same reflex. An API wants a Stream, you have a string or a buffer, so you allocate a MemoryStream, copy everything into it, and hand it over. We've done it for twenty years. It's such muscle memory that nobody even registers it as a copy anymore.
.NET 11 Preview 6 deletes that copy. Four new stream adapter types in System.IO (shipped in dotnet/runtime PR #129811) wrap in-memory data as a Stream directly: strings, Memory<byte>, ReadOnlySequence<byte>. Zero intermediate allocation. It's a small feature buried in a big release (I covered the headliners in my .NET 11 Preview 6 roundup), but it quietly removes allocations from a thousand code paths.
Here's what each one wraps, when to reach for it, and where it actually matters.
StringStream: The Glue-Code Killer
StringStream reads a string or ReadOnlyMemory<char> as a stream, encoding on the fly with whatever Encoding you specify.
This is the most common flavour of the reflex. YAML config as a string, a JSON payload from a message, an XML blob from a database column. The parser only accepts a Stream. The old answer was Encoding.GetBytes into a fresh byte[], then a MemoryStream around that. Two allocations and a full copy of your data, just to satisfy a method signature.
Now the string stays put. StringStream encodes chunks as the consumer reads, so the bytes never exist as one contiguous allocation. For a 2 MB config string, that's 2 MB of byte[] you no longer allocate and no longer hand the GC.
ReadOnlyMemoryStream: Buffers as Read-Only Streams
ReadOnlyMemoryStream exposes a ReadOnlyMemory<byte> as a read-only stream.
The scenario: you already have the bytes. A pooled buffer, a slice of a larger array, a memory-mapped region. Some API insists on a Stream anyway. Posting a buffer as an HTTP request body via StreamContent is the classic case. Before, you'd call .ToArray() on the memory (a copy) or fight MemoryStream constructor overloads that only accept byte[] (a copy, or ugly array-unwrapping gymnastics).
One honest note: it's read-only by design. If the consumer needs to write back, this isn't your type. That's the next one.
WritableMemoryStream: Fixed-Size, and That's the Point
WritableMemoryStream exposes a writable Memory<byte> as a fixed-size stream.
Read that again: fixed size. It will not grow. Sounds like a limitation. It's the feature. A growable MemoryStream resizes by allocating a bigger array and copying everything over, repeatedly, as it grows. When you already know the output size (a fixed-length header, a serialized record with a known layout, a slot in a pooled buffer), WritableMemoryStream lets a Stream-writing API fill your buffer in place. No resize. No copy. No surprise allocations.
If you genuinely don't know the output size up front, keep using MemoryStream or a pooled writer. This adapter is for when the destination already exists.
ReadOnlySequenceStream: The Pipelines Hero
ReadOnlySequenceStream exposes a ReadOnlySequence<byte> as a stream without flattening it. This is the one I've personally wanted for years.
If you've worked with System.IO.Pipelines, you know the shape of the problem. PipeReader hands you a ReadOnlySequence<byte>, a linked list of buffer segments, deliberately non-contiguous so the pipe can reuse pooled memory without copying. Beautiful design, right up until you need to feed that data to anything expecting a Stream. JsonSerializer.DeserializeAsync, a GZipStream decompressor, a hash computation, a legacy parser. Every one of those forced you to flatten the sequence into a single contiguous byte[]. One big allocation and a full copy, per message. Which undoes exactly the copy-avoidance that made you pick pipelines in the first place.
I've hit this repeatedly building real-time systems on Orleans and pipelines, the same architecture I wrote about in my Orleans event sourcing on .NET 10 post. Message framing over sockets, per-message deserialization, thousands of times a second. Every sequence.ToArray() in that hot loop was a tax I paid because the Stream abstraction and the ReadOnlySequence abstraction refused to talk to each other.
ReadOnlySequenceStream makes them talk. It streams directly over the segments. The consumer reads across segment boundaries transparently, and no contiguous copy ever exists. The release notes say it plainly: it's "especially useful with System.IO.Pipelines, because it streams directly over the segments of a ReadOnlySequence<byte> instead of allocating a contiguous copy."
Before and After
The string case first. This is the official Preview 6 example, and it's exactly the glue code you've written a hundred times:
// BEFORE: two allocations and a full copy, just to satisfy a signature
byte[] bytes = Encoding.UTF8.GetBytes(yamlText);
using var config = new MemoryStream(bytes);
var settings = ParseConfiguration(config);
using System.IO;
using System.Text;
// AFTER: pass a string to an API that takes a Stream, no intermediate byte[] needed
using Stream config = new StringStream(yamlText, Encoding.UTF8);
var settings = ParseConfiguration(config);
// Expose an existing buffer as a read-only stream, for example as an HTTP request body
ReadOnlyMemory<byte> payload = GetPayload();
using Stream body = new ReadOnlyMemoryStream(payload);
await httpClient.PostAsync(uri, new StreamContent(body));
And the pipelines case, the one that matters on hot paths:
// BEFORE: flatten the whole sequence into one contiguous copy per message
ReadOnlySequence<byte> sequence = result.Buffer.Slice(0, messageLength);
byte[] flattened = sequence.ToArray(); // allocation + copy
using var stream = new MemoryStream(flattened);
var message = await JsonSerializer.DeserializeAsync<OrderEvent>(stream);
// AFTER: stream over the segments directly, zero copies
ReadOnlySequence<byte> sequence = result.Buffer.Slice(0, messageLength);
using Stream stream = new ReadOnlySequenceStream(sequence);
var message = await JsonSerializer.DeserializeAsync<OrderEvent>(stream);
The fix isn't clever. That's what I like about it. No new runtime magic. Just someone finally shipping the adapter types we've all half-written in our own utility folders, done properly, in the box.
When Not to Care
If the code path runs once at startup, don't touch it. A MemoryStream copy of your config file costs effectively nothing, and rewriting working code for a one-time allocation is performance theatre. These adapters earn their keep on hot paths: per-request parsing, message-processing loops, anywhere the copy multiplies by throughput. Optimise the loop, not the launch.
Go Grep Your Codebase
Here's the concrete ask: install Preview 6, then grep your codebase for new MemoryStream(. I'd bet most hits aren't real streams at all. They're adapters, copies you made because two abstractions wouldn't shake hands. Each one is now a one-line swap and a deleted allocation.
That's the drastic-simplification pattern in miniature. The improvement isn't adding something clever. It's removing a copy we'd all stopped seeing.
The full details are in the official Preview 6 announcement and the stream adapters section of the release notes. For everything else in this release, async validation, unions, SignalR auth refresh, my Preview 6 roundup has you covered.