BufferedStream.WriteByte had a surprising side effect through .NET 9: the call at its internal capacity boundary also called Flush() on the wrapped stream. The .NET 10 BufferedStream WriteByte behavior removes that implicit flush. Bytes can still move to the underlying stream when the buffer needs room, but that capacity boundary no longer becomes an accidental flush boundary.
That distinction matters when a custom stream, protocol adapter, compressor, or test double gives Flush() observable meaning. The application may still deliver the right bytes eventually while missing the side effect it previously got at a particular point.
Why .NET 10 BufferedStream WriteByte changed
Microsoft documents this as a .NET 10 behavioral change. WriteByte used to differ from the other BufferedStream.Write methods by flushing the underlying stream when a byte write reached its capacity boundary. .NET 10 removes that inconsistency.
This is stable behavior in .NET 10 LTS, not a preview feature. I verified the sample with SDK 10.0.303 and the 10.0.11 runtime, alongside runtime 9.0.18 for the old contract.
The word “flush” needs care here. BufferedStream can write buffered bytes to its destination to make room without calling the destination's Flush() method. The breaking change is about that method call, not a promise that every byte stays inside BufferedStream until disposal.
Reproduce the old and new flush boundary
I prefer an executable contract over guessing from a MemoryStream.Length check. The complete multi-target verifier wraps a memory stream and counts both writes and flushes:
internal sealed class TrackingStream : MemoryStream
{
public int FlushCalls { get; private set; }
public int WriteCalls { get; private set; }
public override void Flush()
{
FlushCalls++;
base.Flush();
}
public override void Write(ReadOnlySpan<byte> buffer)
{
WriteCalls++;
base.Write(buffer);
}
public override void Write(byte[] buffer, int offset, int count)
{
WriteCalls++;
base.Write(buffer, offset, count);
}
}
The test uses a four-byte buffer and writes exactly four bytes:
byte[] payload = [1, 2, 3, 4];
using TrackingStream sink = new();
using BufferedStream buffered = new(sink, bufferSize: 4);
foreach (byte value in payload)
{
buffered.WriteByte(value);
}
Console.WriteLine(
$"flushes={sink.FlushCalls}, bytes={Convert.ToHexString(sink.ToArray())}");
The result before an explicit flush is precise:
net9.0: flushes=1, writes=1, bytes=010203
net10.0: flushes=0, writes=1, bytes=010203
Both runtimes write the first three bytes to make room and retain the fourth byte in the buffer. Only .NET 9 also calls Flush() on the destination. That is the regression test worth preserving.
After buffered.Flush(), both targets contain 01020304 in order. The explicit call adds exactly one destination flush and one write for the remaining byte. The sample asserts seven contracts on each framework and repeats with byte-identical output.
Make the boundary explicit
The fix is not to recreate the old buffer-size accident. Put the flush where the application owns a real boundary:
foreach (byte value in encodedRecord)
{
buffered.WriteByte(value);
}
buffered.Flush(); // The logical record is complete.
For an asynchronous pipeline, use FlushAsync(cancellationToken) at the equivalent record or batch boundary. The BufferedStream.Flush contract sends buffered data to the underlying stream and clears the buffer. It is clearer than depending on an internal capacity that could change with construction or implementation details.
I would also add a contract test around the stream that actually matters. A counter-based fake is good for proving call order. A protocol fixture can go further and verify that one complete record becomes observable only after the chosen flush. Neither test needs a network service.
Avoid flushing after every byte. That discards the batching benefit that justified BufferedStream in the first place. A record, frame, or bounded batch is usually a better unit.
Limits and when not to use this
If the code only needs all bytes when the stream is disposed, and the wrapped stream gives Flush() no special side effect, no change may be required. Disposal still provides the final boundary.
An explicit managed flush is also not automatically a durability guarantee. A memory stream, network stream, compressor, and file stream can assign different meaning to Flush(). If the requirement is physical-media durability, test and invoke the storage-specific durability operation after draining BufferedStream.
The main migration rule is small: do not use internal buffer capacity as application control flow. Name the boundary your protocol or storage contract actually needs, flush there, and verify it offline.
Where does your code need that boundary: after a record, after a batch, or only during shutdown?
Happy debugging!
Top comments (0)