The Misconception That TCP Lets You Receive in the Same Units You Send — Designing Reception Around a Byte Stream

· Updated: · · TCP, Socket, Network, .NET, C#, Protocol Design, Operations, Legacy Asset Reuse

Revision history (1 updates, last updated Sep 1, 2026)

A log of the changes made to this article. Where a pre-update version was archived, it stays readable at a permanent DOI link.

Retranslated as a full translation of the Japanese original. The previous English version was an abridgement that carried only part of the source, so sections, tables, Mermaid diagrams, figure captions and FAQ entries were missing. All of them have been restored to match the Japanese original, and the technical claims are the same as in the Japanese version. Read the version before this update (DOI: 10.5281/zenodo.21614663)
First published
Cite this article(DOI: 10.5281/zenodo.21614662)

This article is archived on Zenodo. Below are both the DOI that always resolves to the latest version and the DOI pinned to the version you are reading.

Go Komura (2026). The Misconception That TCP Lets You Receive in the Same Units You Send — Designing Reception Around a Byte Stream. KomuraSoft LLC. https://doi.org/10.5281/zenodo.21614662 https://comcomponent.com/en/blog/2026/06/09/001-tcp-send-receive-message-framing/

DOI (latest version)
10.5281/zenodo.21614662
DOI (this version)
10.5281/zenodo.22220524

1. What to Understand First

There is a very common misconception in TCP programming.

It is this:

For every unit the sender passes to Send / Write, the receiver can read that same unit with Receive / Read.

For example, suppose the sender transmits like this:

Send("LOGIN\n")
Send("GET /items\n")
Send("QUIT\n")

You then assume the receiver can read it back in three calls:

Receive() => "LOGIN\n"
Receive() => "GET /items\n"
Receive() => "QUIT\n"

But with TCP, that is not guaranteed.

In reality, any of the following can happen:

Receive() => "LOGIN\nGET /items\nQUIT\n"
Receive() => "LOG"
Receive() => "IN\nGET /ite"
Receive() => "ms\nQUIT\n"
Receive() => "LOGIN\nGET /items\n"
Receive() => "QUIT"
Receive() => "\n"

All of these are perfectly normal for TCP.

What TCP guarantees, roughly speaking, is that “the bytes you send arrive in order, without duplication, without loss.” What it does not guarantee is that “the units the application passed to Send are preserved as the units the receiver gets from Receive.”

That is why any application using TCP needs a mechanism on the receiving side to determine “of the bytes I just received, where does one message start and end?”

This is called framing in the application protocol.

This article lays out the misconceptions that arise around TCP’s Send and Receive, and the correct way to handle them in .NET / C#.

The code in this article is published on GitHub as a complete buildable, runnable sample set (a library, a loopback TCP demo, and unit tests reproducing fragmentation, coalescing, and mid-stream disconnection).

tcp-send-receive-message-framing - komurasoft-blog-samples (GitHub)

Assumptions in this article

Item Assumption
Language and runtime C# 8 or later syntax (range operators, nullable reference types) and the Stream.ReadAsync overload that takes a Memory<byte>. Assume .NET Core 3.1 or later / .NET 5 or later
Networking API Reception is written with async/await against the NetworkStream obtained from a TcpClient, that is, against a Stream
Using Socket directly The reception model is the same. Socket.Receive / ReceiveAsync are handled the same way: trust nothing but the returned byte count, and loop until you have read everything you need. The difference is on the sending side, where Socket.Send requires you to check the return value. Section 10 covers this
.NET Framework The design is identical, but range operators and the overload taking a Memory<byte> are unavailable, so the loops have to be rewritten with Read(byte[], int, int)
Stream.ReadExactly Stream.ReadExactly / ReadExactlyAsync, mentioned in section 8, require .NET 7 or later

This article is about application protocol design, so the conclusions do not change based on whether you set NoDelay or put TLS in the path. Sections 13 and 14 explain why.

In the diagram a solid line marks a relation that always holds and a dashed line marks a conditional one (the conditions are given per relation on the detail page). The full list of relations (19 in total, with evidence and certainty) and the definitions of the main concepts are collected on the knowledge map detail page (in Japanese). Data: JSON-LD / Turtle

2. TCP Carries Bytes, Not “Messages”

The first thing to get right is to stop thinking of TCP as a message queue.

TCP treats the data handed to it by the application as a continuous stream of bytes.

Even if the sender calls Send three times like this,

Send("ABC")
Send("DEF")
Send("GHI")

from TCP’s point of view the result is simply a 9-byte flow:

ABCDEFGHI

Within that flow, the application-imposed boundaries of

ABC | DEF | GHI

do not survive.

The receiver reads “whatever is in the receive buffer” at some moment in time. So the results it sees can look like this:

Sender calls Example of what the receiver sees
Send("ABC"), Send("DEF") One Receive() returning "ABCDEF"
Send("ABCDEF") Two Receive() calls returning "AB", "CDEF"
Send("ABC"), Send("DEF"), Send("GHI") Three Receive() calls returning "A", "BCDEFG", "HI"
A UTF-8 character such as Send("\u3042") The split can land in the middle of a multibyte character

The important point is that none of this is a malfunction.

Most of the bugs that look like “data occasionally goes missing,” “several messages get glued together,” or “the text comes back garbled” are not TCP misbehaving. They are a design mistake on the receiving side, which is treating TCP as if it delivered messages.

3. Why It Looks Like You Can Receive per Send

The reason this misconception survives is that on a local machine, with small payloads, things often happen to work exactly as expected.

Development environments make it easy for these conditions to line up:

  • Client and server run on the same machine, or on a nearby network
  • The data volume is small
  • The peer reads immediately
  • CPU and network capacity are plentiful
  • Testing is manual, so timing jitter is minimal
  • Receive is called right after Send

Under those conditions, it can look as though one Send is always readable with one Receive.

Production changes the conditions:

  • Data accumulates in the OS send and receive buffers
  • Several small sends get batched together
  • A large send gets split up by TCP segments or by receive buffer limits
  • Scheduling of the receiving thread is delayed
  • Layers such as TLS, proxies, load balancers, and VPNs sit in the path
  • Network latency and congestion occur
  • The Nagle algorithm and delayed ACKs come into play

The result is the nastiest class of bug: “it worked in development but breaks occasionally in production.”

In network code, this kind of “happens to work” is the most dangerous state of all.

4. Common Fragile Reception Code

Code like the following, for example, is dangerous:

byte[] buffer = new byte[4096];
int read = await stream.ReadAsync(buffer, cancellationToken);

if (read == 0)
{
    // The peer closed the connection cleanly
    return;
}

string message = Encoding.UTF8.GetString(buffer, 0, read);
await HandleMessageAsync(message, cancellationToken);

This code assumes that one ReadAsync yields one message, but that assumption does not hold for TCP.

There are three main problems.

The first is that a single message gets fragmented.

Sent:       {"command":"login","user":"komura"}\n
Received 1: {"command":"login",
Received 2: "user":"komura"}\n

Trying to parse “Received 1” alone as JSON fails.

The second is that several messages get coalesced.

Sent 1:   {"command":"login"}\n
Sent 2:   {"command":"get"}\n
Received: {"command":"login"}\n{"command":"get"}\n

Trying to parse that as a single JSON document fails.

The third is a split landing on a character encoding boundary.

In UTF-8, one character can span several bytes. Nothing guarantees that a ReadAsync boundary coincides with a character boundary.

So if you stringify every chunk of received bytes immediately with Encoding.UTF8.GetString, the result can break whenever a split lands inside a multibyte character.

The rule is not “stringify as soon as it arrives” but “accumulate bytes until the message boundary is known, and decode once one full message is available.”

5. Never Use DataAvailable to Detect End of Message

Here is another pattern you see often:

var ms = new MemoryStream();
byte[] buffer = new byte[4096];

while (stream.DataAvailable)
{
    int read = await stream.ReadAsync(buffer, cancellationToken);
    if (read == 0)
    {
        break;
    }

    ms.Write(buffer, 0, read);
}

byte[] message = ms.ToArray();

This is dangerous too. What DataAvailable reports is whether, at that instant, there is readable data in the local receive buffer. It says nothing about whether one application-level message is complete.

Suppose a message is 100 bytes long. DataAvailable can turn true the moment the first 40 bytes arrive, and go back to false immediately after you read those 40 bytes. The remaining 60 bytes may show up slightly later.

If you interpret DataAvailable == false as “end of message,” you process a partial message as if it were whole.

DataAvailable can have a place in optimizing a read loop or making a non-blocking check, but it is safer to keep it out of protocol boundary decisions.

6. The Correct Mindset: Separate “Receiving” from “Interpreting”

TCP reception gets much easier to design once you separate these two concerns:

Receiving:    read the bytes that arrived from TCP and append them to a buffer
Interpreting: carve one application-level message out of that buffer

Receive / Read do nothing more than read bytes. Where one message ends is something the application protocol has to decide.

There are four common approaches:

Approach What it does Good fit for
Fixed length Always treats a set number of bytes as one message Legacy equipment, binary telegrams, control systems
Delimiter Treats everything up to a specific byte sequence, such as \n, as one message Commands, logs, NDJSON, simple protocols
Length prefix Puts the payload length at the front, then reads exactly that many bytes Binary, JSON, MessagePack, Protocol Buffers, and so on
Self-describing format Expresses the length or terminator inside the format itself, like HTTP’s Content-Length or chunked encoding Existing protocols, communication that needs to be extensible

Personally, when designing a custom protocol, the length prefix approach is what I look at first. The payload can contain newlines or arbitrary binary, the receiver implementation is unambiguous, and a maximum size limit is easy to add.

7. Length Prefix Basics

In the length prefix approach, messages take this form:

[4-byte payload length][payload]

For example, if the payload is UTF-8 JSON and is 31 bytes long, you send:

00 00 00 1F 7B 22 63 6F 6D 6D 61 6E 64 ...
^---------^ ^------------------------------^
   length                payload

Putting three sends, what the receiver actually sees, and how frames are reconstructed from it onto a single picture gives this:

on to the next frameSend 1payload HELLO00 00 00 05 48 45 4C 4C 4FSend 2payload ABC00 00 00 03 41 42 43Send 3payload QUIT00 00 00 04 51 55 49 54TCP is an ordered byte streamsend boundaries are not carried24 bytes simply arrive in orderRead 1 = 6 bytes00 00 00 05 48 45Read 2 = 11 bytes4C 4C 4F 00 00 00 03 41 42 43 00Read 3 = 7 bytes00 00 04 51 55 49 54Receive bufferbytes from each Read are appended in orderRead the first 4 bytes to completionpayload length = 5Read 5 payload bytes to completionone message complete = HELLODo not discard leftover byteskeep them as the start of the next frame

Figure 1: three sends do not map to three reads on the receiving side; frames are reconstructed by way of the receive buffer

In the figure, Read 1 does not even cover the whole payload length field, and Read 2 mixes the remainder of the first payload, the entirety of the second frame, and the first byte of the third header. The send boundaries and the receive boundaries clearly do not line up.

The receiver processes in this order:

  1. Read exactly 4 bytes
  2. Extract the payload length from those 4 bytes
  3. Validate the payload length
  4. Read exactly that many payload bytes
  5. Process the completed payload as one message
  6. Read the next frame

The crucial point here: even the 4-byte header can be fragmented.

Received 1: 00 00
Received 2: 00 1F 7B 22 63 ...

So being “just the header” does not mean one Read will yield 4 bytes.

Same for the payload. It is entirely normal for Read to return fewer bytes than requested. When the required byte count is known, you must write a loop that reads until it is fully satisfied.

8. Reception in .NET: A Length Prefix Implementation

Here is an example of reading length-prefixed frames in .NET / C#.

The first 4 bytes are treated as a big-endian int holding the payload length.

using System.Buffers.Binary;
using System.IO;

public static class LengthPrefixedProtocol
{
    private const int HeaderSize = 4;
    private const int MaxPayloadSize = 1024 * 1024; // 1 MiB. Pick a value that fits your use case

    public static async ValueTask<byte[]?> ReadFrameAsync(
        Stream stream,
        CancellationToken cancellationToken)
    {
        byte[] header = new byte[HeaderSize];

        int headerBytes = await ReadUntilFullOrEndAsync(
            stream,
            header,
            cancellationToken);

        if (headerBytes == 0)
        {
            // The peer shut down cleanly before the next frame started, not mid-frame
            return null;
        }

        if (headerBytes != HeaderSize)
        {
            throw new EndOfStreamException("Frame header was truncated.");
        }

        int payloadLength = BinaryPrimitives.ReadInt32BigEndian(header);

        if (payloadLength < 0 || payloadLength > MaxPayloadSize)
        {
            throw new InvalidDataException(
                $"Invalid payload length: {payloadLength} bytes.");
        }

        byte[] payload = new byte[payloadLength];

        int payloadBytes = await ReadUntilFullOrEndAsync(
            stream,
            payload,
            cancellationToken);

        if (payloadBytes != payloadLength)
        {
            throw new EndOfStreamException("Frame payload was truncated.");
        }

        return payload;
    }

    private static async ValueTask<int> ReadUntilFullOrEndAsync(
        Stream stream,
        Memory<byte> buffer,
        CancellationToken cancellationToken)
    {
        int totalRead = 0;

        while (totalRead < buffer.Length)
        {
            int read = await stream.ReadAsync(
                buffer[totalRead..],
                cancellationToken);

            if (read == 0)
            {
                break;
            }

            totalRead += read;
        }

        return totalRead;
    }
}

The calling side looks like this:

while (true)
{
    byte[]? payload = await LengthPrefixedProtocol.ReadFrameAsync(
        stream,
        cancellationToken);

    if (payload is null)
    {
        // The peer disconnected cleanly on a frame boundary
        break;
    }

    await HandleMessageAsync(payload, cancellationToken);
}

With this implementation it does not matter how many bytes ReadAsync returns at a time. Even if it returns one byte per call, the loop keeps going until the header and payload are fully read.

Conversely, when the OS receive buffer holds data for several messages, only the leading frame is carved out according to its payload length, and the next frame is read on the next iteration.

Note that on current .NET you may have Stream.ReadExactly / ReadExactlyAsync available, which lets you hand the “read exactly this many bytes” logic to the standard API. Even then, how you handle connection termination, and how you distinguish a clean shutdown before a frame from an abnormal one in the middle of a frame, is still something your application has to design.

9. The Sending Side

The sender follows the same frame format.

using System.Buffers.Binary;
using System.IO;

public static class LengthPrefixedProtocolWriter
{
    private const int HeaderSize = 4;
    private const int MaxPayloadSize = 1024 * 1024;

    public static async ValueTask WriteFrameAsync(
        Stream stream,
        ReadOnlyMemory<byte> payload,
        CancellationToken cancellationToken)
    {
        if (payload.Length > MaxPayloadSize)
        {
            throw new InvalidDataException(
                $"Payload is too large: {payload.Length} bytes.");
        }

        byte[] header = new byte[HeaderSize];
        BinaryPrimitives.WriteInt32BigEndian(header, payload.Length);

        await stream.WriteAsync(header, cancellationToken);
        await stream.WriteAsync(payload, cancellationToken);
    }
}

This code calls WriteAsync separately for the header and the payload. Here is where another misconception creeps in: splitting the header and payload into two WriteAsync calls on the sending side does not mean the receiver gets them back in two reads.

The receiver might see this:

Read() => [4-byte header + part of the payload]
Read() => [rest of the payload]

Or it might see this:

Read() => [first 2 bytes of the header]
Read() => [last 2 bytes of the header + the whole payload + the header of the next frame]

That is exactly why the receiver decides based on “how many bytes have I read against the frame format,” never on “how many times have I called Read.”

10. When Using Socket.Send Directly, the Sender Must Check the Return Value Too

If you are using NetworkStream.Write / WriteAsync, you can generally treat them as APIs that write the whole range you gave them.

Socket.Send, used directly, needs more care with its return value.

Socket.Send returns the number of bytes it managed to send. On non-blocking sockets in particular, it can succeed with fewer bytes than you requested.

So if you use Socket.Send directly, the sending side needs its own “keep going until everything is out” loop.

using System.Net.Sockets;

public static async ValueTask SendAllAsync(
    Socket socket,
    ReadOnlyMemory<byte> buffer,
    CancellationToken cancellationToken)
{
    while (!buffer.IsEmpty)
    {
        int sent = await socket.SendAsync(
            buffer,
            SocketFlags.None,
            cancellationToken);

        if (sent == 0)
        {
            throw new IOException("Socket was closed while sending data.");
        }

        buffer = buffer[sent..];
    }
}

That said, “it was sent” here does not mean “the peer application processed that message.” Success of a send API is a separate thing from a success response at the application protocol level.

If what you need to confirm is a business outcome such as “the order was accepted,” “the file was saved,” or “the command was executed,” then you have to define an ACK or response message in the protocol rather than relying on a successful TCP send.

11. Caveats for the Delimiter Approach

Text protocols sometimes use newline delimiters.

LOGIN komura secret\n
GET item-001\n
QUIT\n

The approach is easy to follow and fits log-like and command-like formats well.

There are things to watch for, though:

  • Decide on an escaping rule for delimiters appearing inside the payload
  • Decide how \r\n and \n are handled
  • Decide on a maximum line length
  • Do not accumulate memory without bound while waiting for a delimiter
  • Make sure a split inside a multibyte UTF-8 character does not break anything

The following code in particular is one to avoid:

int read = await stream.ReadAsync(buffer, cancellationToken);
string text = Encoding.UTF8.GetString(buffer, 0, read);

foreach (string line in text.Split('\n'))
{
    await HandleLineAsync(line, cancellationToken);
}

It accounts neither for the possibility that the end of the received range falls in the middle of a line, nor for a split landing inside a UTF-8 character.

If you use newline delimiters, do at least “accumulate bytes, search for the newline byte, and decode only once a full line is assembled,” or use an API that reads lines off the stream itself, such as StreamReader.ReadLineAsync.

Even with StreamReader.ReadLineAsync, though, you still need a design for maximum line length, timeouts, cancellation, and connection termination.

12. Caveats for the Fixed-Length Approach

With fixed-length telegrams, you settle on a rule such as “one message is always 128 bytes.” You find this in older business systems, control systems, and device integrations.

The thinking is the same for fixed length.

1 message = 128 bytes

Given that, the receiver loops until it has read 128 bytes.

byte[] message = new byte[128];
int read = await ReadUntilFullOrEndAsync(stream, message, cancellationToken);

if (read != message.Length)
{
    throw new EndOfStreamException("Fixed-length message was truncated.");
}

await HandleMessageAsync(message, cancellationToken);

Here too, one ReadAsync will not necessarily return 128 bytes.

Fixed length is easy to implement because the boundaries are unambiguous, but it makes variable-length data awkward, future extension difficult, padding a nuisance, and byte counts unstable across character encoding conversions.

13. Disabling Nagle Does Not Solve the Message Boundary Problem

When you want small pieces of data to go out immediately, Socket.NoDelay = true is a natural thing to consider. It disables the Nagle algorithm.

But NoDelay is a setting about send latency and efficiency, about how small sends get batched. It is not a setting that preserves Send units as Receive units.

In other words, setting NoDelay = true does not solve any of these:

  • One Send splitting across multiple Receive calls
  • Multiple Send calls coalescing into one Receive
  • Splits landing in the middle of a character
  • The receiver being unable to determine message boundaries

NoDelay is meaningful as a latency adjustment, but it is no substitute for framing.

14. The Same Thinking Applies with TLS and SslStream

When you wrap the connection in TLS with SslStream, the picture from the application’s point of view is essentially unchanged.

TLS has an internal unit called the TLS record, but that is not an application message boundary.

SslStream.ReadAsync will not necessarily return one application-level message per call either.

So with or without TLS, the application layer must design one of the following:

  • A length prefix
  • A delimiter such as a newline
  • A fixed length
  • An existing protocol format

TLS is a layer for encryption and authentication. It is not a layer that creates message boundaries for you.

15. Error Handling to Keep in Mind in the Receive Loop

In TCP reception, it matters that you handle disconnects and premature termination explicitly, not just the happy path.

When Read / Receive returns 0, that generally means the peer has cleanly finished sending.

At the application protocol level, though, you need to distinguish two cases:

State How to treat it
Termination with 0 bytes before the next frame is read Can sometimes be treated as a clean shutdown
Termination in the middle of the frame header or the payload A half-finished frame, so treat it as an error

With a length prefix, the reasoning goes something like this:

Disconnect on a frame boundary:
  can be treated as a clean shutdown

Disconnect after receiving only 2 of the 4 header bytes:
  protocol error

Disconnect after receiving only 60 bytes of a declared 100-byte payload:
  protocol error

Making this distinction up front makes log investigation far easier.

Instead of just “the peer disconnected,” being able to emit

Frame payload was truncated. expected=100 actual=60

makes it much easier to suspect an abnormal termination on the peer side, a timeout, or a protocol mismatch.

16. Always Set a Maximum Size

In the length prefix approach, the payload length sits at the front.

The danger is a peer that declares an enormous length.

FF FF FF FF

Feeding that straight into an array allocation makes the application try to reserve a huge amount of memory and destabilizes it.

So the receiver must always set a maximum size.

private const int MaxPayloadSize = 1024 * 1024;

if (payloadLength < 0 || payloadLength > MaxPayloadSize)
{
    throw new InvalidDataException(
        $"Invalid payload length: {payloadLength} bytes.");
}

The maximum comes from business requirements. For commands, 64 KiB may be plenty; for sending images or files, a different transfer mechanism or streaming may be the better answer. What matters is that you never design something that will, in theory, accept a payload of any size.

17. In String Protocols, Count Bytes, Not Characters

What TCP carries is bytes, not strings.

So when you put a payload length into a length prefix, that length is normally a byte count, not a character count.

Take the following string, encoded as UTF-8:

こんにちは

That is 5 characters, but 15 bytes in UTF-8.

If you put 5 in the protocol length field, the receiver reads only 5 bytes of payload and cuts off in the middle of a character.

The sender must always compute the length from the encoded byte array.

string json = "{\"message\":\"こんにちは\"}";
byte[] payload = Encoding.UTF8.GetBytes(json);

await LengthPrefixedProtocolWriter.WriteFrameAsync(
    stream,
    payload,
    cancellationToken);

The receiver reads the frame payload to completion as bytes, and only then turns it back into a string.

byte[]? payload = await LengthPrefixedProtocol.ReadFrameAsync(
    stream,
    cancellationToken);

if (payload is not null)
{
    string json = Encoding.UTF8.GetString(payload);
    await HandleJsonAsync(json, cancellationToken);
}

In that order, a Read splitting in the middle of a UTF-8 character causes no trouble at all.

18. Also Watch for Application-Level Interleaving from Concurrent Writes

One more thing that gets overlooked surprisingly often is concurrent writes.

Suppose several tasks write frames to the same TCP connection at the same time.

_ = WriteFrameAsync(stream, messageA, cancellationToken);
_ = WriteFrameAsync(stream, messageB, cancellationToken);

Without control over this, you can get application-level interleaving like this:

header of A
header of B
payload of A
payload of B

The receiver reads A’s header and then expects A’s payload. When B’s header cuts in instead, the protocol is broken.

So it is safer to serialize writes to a single connection. A SemaphoreSlim or a send queue, for example, keeps frame-level writes from mixing.

private readonly SemaphoreSlim _sendLock = new(1, 1);

public async ValueTask SendFrameSafelyAsync(
    Stream stream,
    byte[] payload,
    CancellationToken cancellationToken)
{
    await _sendLock.WaitAsync(cancellationToken);

    try
    {
        await LengthPrefixedProtocolWriter.WriteFrameAsync(
            stream,
            payload,
            cancellationToken);
    }
    finally
    {
        _sendLock.Release();
    }
}

TCP preserves the order of bytes. But if the application writes bytes from several tasks in a jumbled order, TCP will faithfully deliver that jumbled order.

19. Deliberately Fragment and Coalesce in Tests

If you test TCP reception the obvious way, you tend to miss the “happens to work” state.

So in tests, deliberately create these patterns:

Test aspect Example
Bytes arrive one at a time Both header and payload are Read one byte at a time
Disconnect mid-header Only 2 of the 4 header bytes arrive before termination
Disconnect mid-payload Only 60 of a declared 100 payload bytes arrive before termination
Multiple frames coalesced Two frames sit in a single internal buffer
Enormous declared size Send a payload length exceeding the maximum
Zero-byte payload Confirm whether payload length 0 is allowed
UTF-8 splits Byte sequences of Japanese text or emoji are split mid-character

In unit tests, you do not need real TCP sockets. Substituting a Stream that “can only be read in a specified chunk size” makes the reception logic much easier to verify.

Reproducing fragmentation: wrap the Stream

You do not need a dedicated library. Deriving from Stream and capping the number of bytes Read returns is enough.

using System;
using System.IO;
using System.Threading;
using System.Threading.Tasks;

// A stream that returns at most maxChunkSize bytes per Read.
// It only wraps an inner stream, so the reception code under test stays untouched.
public sealed class ChunkedReadStream : Stream
{
    private readonly Stream _inner;
    private readonly int _maxChunkSize;

    public ChunkedReadStream(Stream inner, int maxChunkSize)
    {
        if (inner is null) throw new ArgumentNullException(nameof(inner));
        if (maxChunkSize < 1) throw new ArgumentOutOfRangeException(nameof(maxChunkSize));

        _inner = inner;
        _maxChunkSize = maxChunkSize;
    }

    public override int Read(byte[] buffer, int offset, int count)
        => _inner.Read(buffer, offset, Math.Min(count, _maxChunkSize));

    public override ValueTask<int> ReadAsync(
        Memory<byte> buffer,
        CancellationToken cancellationToken = default)
        => _inner.ReadAsync(
            buffer[..Math.Min(buffer.Length, _maxChunkSize)],
            cancellationToken);

    public override bool CanRead => true;
    public override bool CanSeek => false;
    public override bool CanWrite => false;
    public override long Length => throw new NotSupportedException();

    public override long Position
    {
        get => throw new NotSupportedException();
        set => throw new NotSupportedException();
    }

    public override void Flush() { }
    public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException();
    public override void SetLength(long value) => throw new NotSupportedException();
    public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException();
}

With this in place, “only one byte arrives at a time” and “two frames arrive at once” are the same test with a different argument.

using System.Buffers.Binary;
using System.IO;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Xunit;

public class LengthPrefixedProtocolTests
{
    // Tests LengthPrefixedProtocol.ReadFrameAsync from section 8
    [Theory]
    [InlineData(1)]      // header and payload arrive one byte at a time
    [InlineData(3)]      // the header is cut in the middle
    [InlineData(1024)]   // both frames arrive together
    public async Task RestoresTwoFramesRegardlessOfChunkSize(int chunkSize)
    {
        using var source = new MemoryStream();
        WriteFrame(source, "HELLO");
        WriteFrame(source, "ABC");
        source.Position = 0;

        using var stream = new ChunkedReadStream(source, chunkSize);

        byte[]? first = await LengthPrefixedProtocol.ReadFrameAsync(
            stream, CancellationToken.None);
        byte[]? second = await LengthPrefixedProtocol.ReadFrameAsync(
            stream, CancellationToken.None);
        byte[]? afterLast = await LengthPrefixedProtocol.ReadFrameAsync(
            stream, CancellationToken.None);

        Assert.NotNull(first);
        Assert.NotNull(second);
        Assert.Equal("HELLO", Encoding.UTF8.GetString(first!));
        Assert.Equal("ABC", Encoding.UTF8.GetString(second!));
        Assert.Null(afterLast); // clean shutdown on a frame boundary
    }

    private static void WriteFrame(Stream destination, string text)
    {
        byte[] payload = Encoding.UTF8.GetBytes(text);
        byte[] header = new byte[4];
        BinaryPrimitives.WriteInt32BigEndian(header, payload.Length);

        destination.Write(header, 0, header.Length);
        destination.Write(payload, 0, payload.Length);
    }
}

Mid-stream disconnects are just as easy to reproduce: write only part of the data to the MemoryStream. Writing 2 of the 4 header bytes instead of calling WriteFrame, for instance, gives you “disconnected mid-header,” and you can assert that ReadFrameAsync throws EndOfStreamException.

Forcing fragmentation over loopback

If you want an integration test that goes over real TCP, have the sender deliberately write the frame in two calls with a pause in between.

using System;
using System.Net.Sockets;
using System.Threading;
using System.Threading.Tasks;

public static class SplitSender
{
    // Sends frame split at firstChunkSize bytes.
    // Without NoDelay, the Nagle algorithm can merge the two halves into
    // a single segment, in which case no fragmentation occurs.
    public static async Task SendSplitAsync(
        TcpClient client,
        byte[] frame,
        int firstChunkSize,
        CancellationToken cancellationToken)
    {
        if (client is null) throw new ArgumentNullException(nameof(client));
        if (frame is null) throw new ArgumentNullException(nameof(frame));
        if (firstChunkSize < 1 || firstChunkSize >= frame.Length)
        {
            throw new ArgumentOutOfRangeException(nameof(firstChunkSize));
        }

        client.NoDelay = true;
        NetworkStream stream = client.GetStream();

        await stream.WriteAsync(frame.AsMemory(0, firstChunkSize), cancellationToken);
        await Task.Delay(50, cancellationToken);
        await stream.WriteAsync(frame.AsMemory(firstChunkSize), cancellationToken);
    }
}

Pass 2 as firstChunkSize to try “cut in the middle of the 4-byte header,” or the payload length plus 2 to try “cut in the middle of the payload.”

This does not guarantee fragmentation as a matter of TCP semantics, though. It merely sets up conditions under which the boundaries almost always shift in practice. Tests that need to be deterministic belong on the unit test side, with a substituted Stream rather than a socket.

You do want genuine integration tests over TCP as well, but pulling the receive parser out as pure logic over a Stream first makes it far easier to test.

The quality bar for network code is not “it works when sent normally” — it is “it behaves as designed when fragmented, when coalesced, and when cut off mid-stream.”

20. How to Observe the “It Only Breaks in Production Sometimes” Case

Framing bugs stay hidden in development and surface only in production, and only intermittently. The way the data gets split changes for the first time when volumes grow, when the link slows down, or when the peer’s implementation changes.

The first thing to establish is whether what is broken is the byte stream on the sending side or the reconstruction logic on the receiving side. Answering just that question halves the area you have to investigate.

There are three tools to pick between:

Tool What it tells you Caveats
Application logs The expected byte count versus the count actually read, the length of each frame carved out, and the timing of disconnects The first thing to put in place. Always emit both expected and actual. One without the other only tells you “it came up short”
Wireshark The bytes that actually went over the wire, TCP segment boundaries, retransmissions, and whether an RST occurred Seeing loopback traffic (127.0.0.1) on Windows requires Npcap. On Wireshark 3.0.0 or later, pick “Adapter for loopback traffic capture” from the interface list
pktmon Captures with nothing but stock Windows. The resulting ETL can be converted to pcapng and opened in Wireshark pktmon.exe ships by default only on Windows 10 build 19041 and later

Once the capture is open, look at it in this order:

  1. Use Follow > TCP Stream to see the reassembled byte sequence. Check whether the 4 length-prefix bytes hold the value you expect
  2. If they do, the sender is building frames correctly, and the problem is in the receiver’s reconstruction logic
  3. If they do not, suspect the sender’s frame assembly, or the interleaving from concurrent writes described in section 18
  4. If there is an RST or a mid-stream disconnect, check how the receiver handles being cut off inside a frame

There is one point here that is easy to confuse.

A TCP segment boundary is not an application message boundary either. If Wireshark shows one message fitting neatly into one segment, that is a coincidence. The units the receiving application gets back from Read do not line up with segment boundaries any more than they line up with send boundaries. A capture is for confirming the bytes that actually went over the wire, not for reading off “this is one message.”

On top of that, when you capture on the sending host, segmentation offload (LSO / TSO) can record packets larger than the MTU. That is not what actually went over the wire. If the size of the segments themselves is what you care about, capture on the receiving host or on an intermediate host, or temporarily disable offloading and capture then.

21. A Checklist for Fixing Existing Code

When reviewing existing TCP code, these angles make problems easy to find:

Aspect What to check
Receive units Is one Read / Receive being treated as one message?
Return values Is the byte count returned by Read / Receive always used?
Accumulation Are bytes accumulated until a full message is assembled?
Boundaries Is there a rule — fixed length, delimiter, length prefix?
Character encoding Is data stringified before the message is complete?
Maximum length Are lengths and line lengths bounded?
Disconnection Are frame-boundary disconnects distinguished from mid-frame ones?
Sending Is the return value of Socket.Send being ignored?
Concurrency Can writes from multiple tasks to the same connection interleave?
Logging Can expected / actual byte counts be emitted?
Tests Are there tests for fragmentation, coalescing, and mid-stream disconnects?

The most dangerous code looks like this:

int read = socket.Receive(buffer);
string message = Encoding.UTF8.GetString(buffer);
Handle(message);

There are multiple problems:

  • The value of read is not used
  • The entire buffer is stringified
  • One Receive is treated as one message
  • There is no message boundary
  • Mid-character splits are not considered

At minimum, you need to move to this way of thinking:

Append only the read bytes obtained by Receive to the receive buffer
  ↓
Check whether one frame can be carved out of the buffer per the protocol
  ↓
If it can, process it
  ↓
Keep leftover bytes as the start of the next frame
  ↓
If not enough, wait for the next Receive

22. Conclusion

In TCP communication, you cannot count on receiving in the same units you sent. This is not exceptional behavior — it is fundamental to using TCP.

The key points:

  • TCP provides an ordered byte stream, not messages
  • The units of Send / Write calls are not preserved as the receiver’s Receive / Read units
  • One send can split across multiple receives, and multiple sends can coalesce into one receive
  • The receiver must define message boundaries as part of the application protocol
  • For custom protocols, the length prefix approach is often the easiest to work with
  • Include in your design: loops that read the required byte count to completion, maximum sizes, mid-stream disconnects, character encodings, and concurrent writes
  • NoDelay and DataAvailable are not substitutes for message boundaries
  • When something breaks only in production, use a capture to first isolate whether the sender’s byte stream or the receiver’s reconstruction is at fault

Network code looks easy when you only watch the happy path. In reality, communication only becomes stable once you have decided where to draw the boundaries, how to wait when data is short, how to keep the remainder when there is too much, and how to handle being cut off midway.

If you use TCP, Receive does not return messages — it returns just a portion of a byte stream. The responsibility for turning that into messages lies with your application’s protocol design.

References

Recent articles sharing the same tags. Deepen your understanding with closely related topics.

These topic pages place the article in a broader service and decision context.

This article connects naturally to the following service pages.

Frequently Asked Questions

Common questions about the topic of this article.

Why can't I receive data in the same units the sender passed to Send?
Because what TCP guarantees is that the bytes you send arrive in order, without duplication and without loss. It does not guarantee that the unit passed to Send is preserved as the unit the receiver gets back from Receive. TCP carries a continuous stream of bytes rather than messages, so it is perfectly normal for one send to be split across several receives, and for several sends to be coalesced into one receive. The receiver needs a mechanism of its own to decide where a message ends, and that mechanism is called framing.
What framing approaches are available for deciding message boundaries in TCP?
There are four common ones. Fixed length always treats a set number of bytes as one message. A delimiter treats everything up to a specific byte sequence, such as a newline, as one message. A length prefix puts the payload length at the front. A self-describing format expresses the length or the terminator inside the format itself, the way HTTP does with Content-Length. If you are designing your own protocol, the length prefix approach is the first one to consider: the payload can hold arbitrary binary data, and a maximum size limit is easy to enforce.
Does setting Socket.NoDelay to true solve TCP fragmentation and coalescing?
No. NoDelay disables the Nagle algorithm. It is a knob for the latency and efficiency of small sends, not a setting that preserves Send units as Receive units. Even with NoDelay set to true, one Send can still split across multiple Receives, multiple Sends can still coalesce into one Receive, and a split can still land in the middle of a character. It is no substitute for framing.
Why does received TCP data come back as garbled text?
In UTF-8 a single character can span several bytes, and nothing guarantees that a Read boundary lines up with a character boundary. If you run Encoding.UTF8.GetString over every chunk of bytes as it arrives, the result breaks whenever a split lands inside a multibyte character. The fix is to accumulate bytes until the message boundary is known and decode only once a full message is in hand. The payload length in a length prefix is likewise computed in encoded bytes, not in characters.

Author Profile

Profile page for the article author.

Go Komura

Representative of KomuraSoft LLC

Focused on Windows software development, technical consulting, and investigations into failures that are difficult to reproduce.

Back to the Blog