TL;DR: .NET 11 adds StringStream, ReadOnlyMemoryStream, and WritableMemoryStream. They wrap a string, a ReadOnlyMemory<byte>, or a Memory<byte> as a Stream, so you stop copying data into a MemoryStream just to satisfy a method signature.
This ships in .NET 11, which is at Release Candidate 2 with a go-live license, but is not GA yet. Everything below was run on the .NET 11 RC 1 SDK.
The line everybody has written
Some API wants a Stream. You have a string. So you write the line that has been in every codebase since 2002:
using var stream = new MemoryStream(Encoding.UTF8.GetBytes(text));
It works. It also encodes the entire string into a brand new byte[] before the parser reads a single byte.
The same story repeats with buffers. You already have a ReadOnlyMemory<byte> from a pool, a socket, or a slice, and the only way to hand it to a Stream API was to call ToArray() and copy it, or write your own little wrapper class. Most teams have that wrapper somewhere, usually named MemoryStreamButBetter.
What .NET 11 adds
Four new Stream types that sit directly on top of memory you already have:
-
StringStreamreads astringorReadOnlyMemory<char>as bytes in the encoding you pick. -
ReadOnlyMemoryStreamexposes aReadOnlyMemory<byte>as a read-only, seekable stream. -
WritableMemoryStreamexposes aMemory<byte>as a fixed-size, writable stream. -
ReadOnlySequenceStreamexposes aReadOnlySequence<byte>, for example fromSystem.IO.Pipelines, without flattening it.
The first three live in System.IO. ReadOnlySequenceStream lives in System.Buffers, so look there before you decide it is missing. I checked on the .NET 10 SDK: none of the four types exist there.
The minimal version
using System.Text;
using System.Text.Json;
string json = """{"name":"Ada","langs":["C#","F#"]}""";
using var stream = new StringStream(json, Encoding.UTF8);
var doc = JsonDocument.Parse(stream);
Console.WriteLine(doc.RootElement.GetProperty("name")); // Ada
No GetBytes, no intermediate array.
To see whether that matters, I fed a 1,000,000 character string through both versions and read each stream to the end with a 4 KB buffer, measuring with GC.GetAllocatedBytesForCurrentThread() on a warm second run:
| Approach, 1,000,000 ASCII chars | Allocated |
|---|---|
new MemoryStream(Encoding.UTF8.GetBytes(text)) |
1,000,088 bytes |
new StringStream(text, Encoding.UTF8) |
288 bytes |
The old way allocated a copy the size of the whole string. The new way allocated a few hundred bytes. Your garbage collector will not send a thank-you card, but it will be thinking it.
The realistic one: serialize into a pooled buffer
Here is where the byte-side types shine. Rent a buffer, serialize into it, then read it back, with no MemoryStream and no ToArray():
using System.Buffers;
using System.Text;
using System.Text.Json;
byte[] rented = ArrayPool<byte>.Shared.Rent(256);
try
{
int written;
using (var w = new WritableMemoryStream(rented))
{
JsonSerializer.Serialize(w, new { Id = 42, Sku = "A1", Qty = 3 });
written = (int)w.Position;
}
ReadOnlyMemory<byte> json = rented.AsMemory(0, written);
Console.WriteLine(Encoding.UTF8.GetString(json.Span));
using var r = new ReadOnlyMemoryStream(json);
var back = JsonSerializer.Deserialize<JsonElement>(r);
Console.WriteLine($"round trip qty = {back.GetProperty("Qty")}");
}
finally
{
ArrayPool<byte>.Shared.Return(rented);
}
The output:
{"Id":42,"Sku":"A1","Qty":3}
round trip qty = 3
The pool handed back 256 bytes, the serializer wrote 28 of them, and the reader saw exactly those 28. The same trick works for an HTTP body: new StreamContent(new ReadOnlyMemoryStream(payload)) sends a buffer you already own without copying it first.
StringStream is just as handy in tests. XDocument.Load(new StringStream(xml, Encoding.UTF8)) parsed an inline XML literal in my run, which beats keeping a fixture file for a one-line order.
Can I use it?
- .NET 11 only. The types are in the shared framework, so there is no package to add.
- Status: .NET 11 is at RC 2 with a go-live license; GA is still ahead.
-
Docs: each type has an API reference page under the
net-11.0view on Microsoft Learn.
Gotchas
StringStreamcannot seek. In my run it reportedCanSeek = False, and readingLengththrewNotSupportedException. It does not know the byte count without encoding the whole string, which is the work it exists to avoid. If an API insists onLengthor rewinding, this is the wrong type for it.WritableMemoryStreamnever grows. Write past the end of the buffer and you getNotSupportedException: Unable to expand length of this stream beyond its capacity.Serializing a small object into an 8-byte buffer failed exactly that way. Size the buffer for the worst case, or keepMemoryStreamfor output of unknown size.Lengthis the buffer, not what you wrote. AWritableMemoryStreamover an 8-byte buffer reportedLength = 8before a single write, and still 8 after writing 3 bytes. UsePositionto find how many bytes you actually wrote, as the pooled example does.StringStreamdoes not write a BOM. Evennew UTF8Encoding(true)produced just68 69for"hi"in my test, with noEF BB BFin front. If a consumer needs the preamble, write it yourself first.ReadOnlySequenceStreamis not seekable in RC 1. Reading itsLengththrew "Stream does not support seeking." on the RC 1 SDK. Treat it as a forward-only reader over pipeline buffers.
Wrap-up
Four small types, one less copy every time an API demands a Stream. Grab the .NET 11 RC SDK, search your code for new MemoryStream(Encoding.UTF8.GetBytes(, and see how many you can delete.
Sources
- What's new in .NET 11 libraries: In-memory stream adapters - Microsoft Learn
- What's new in .NET 11 - Microsoft Learn
- StringStream class - .NET API reference
- ReadOnlyMemoryStream class - .NET API reference
- WritableMemoryStream class - .NET API reference
- .NET 11 Release Candidate 2 - .NET Blog
This article was written with the help of AI and checked against the sources above.
Top comments (0)