TCP socket framing

TCP hands you a river of bytes, not a stack of letters. To read your messages back out, you have to mark where each one ends.

The idea

It is tempting to think that each time you call send(), the other side gets exactly one matching recv(). It does not. TCP is a stream, not a packet protocol. The kernel is free to split, merge, and re-time your bytes however it likes on the way across the network.

So a single recv() can hand you half a message, exactly one message, or several messages glued end to end. The bytes always arrive in order and are never lost — but the boundaries between your messages are gone. TCP never promised to keep them.

To get discrete messages back, you add framing: a small rule that says where each message stops. The most common rule is length-prefixing — put a fixed-size header in front of every message that says how many payload bytes follow. The receiver reads the length, then reads exactly that many bytes, emits one complete frame, and keeps any leftover bytes in a buffer for the next frame.

incoming chunk — one TCP recv() reassembly buffer completed frames
Press play, or step through one byte-handling decision at a time.

How it works

Keep one growing buffer across calls. Every time bytes arrive, append them, then drain as many complete frames as the buffer currently holds. The inner while loop is what makes coalesced reads work — it keeps slicing frames until the buffer can’t yield another whole one.

import struct

buffer = bytearray()

def on_bytes(chunk):                # called for every TCP recv()
    buffer.extend(chunk)
    while True:
        if len(buffer) < 4:         # not even a full length header yet
            return
        (length,) = struct.unpack(">I", buffer[:4])   # 4-byte big-endian length
        if len(buffer) < 4 + length:
            return                  # header present but payload incomplete
        frame = bytes(buffer[4:4 + length])
        del buffer[:4 + length]     # consume this frame; keep the rest
        handle(frame)               # a complete application message

def send(sock, payload):
    sock.sendall(struct.pack(">I", len(payload)) + payload)

Two returns, two waiting conditions: not enough bytes for the header, and header is here but the payload is short. In both cases you stop and wait for the next recv(). The leftover bytes stay in buffer and become the start of the next frame.

Cost

PropertyLength-prefix framing
Per-frame overheadFixed header, e.g. 4 bytes per message regardless of size
Receiver work per byteO(1) — read the length, jump ahead; no scanning for a delimiter
LatencyYou must buffer the whole frame before dispatching it; a big frame can’t be handled until its last byte arrives
vs. delimiter framingDelimiters (e.g. \n) need no length but force a byte-by-byte scan and an escape rule for the delimiter inside payloads
vs. fixed-size framesFixed frames need no header at all, but waste space padding short messages and can’t carry long ones

A 4-byte big-endian length holds up to ~4 GB. That ceiling is also a liability — see the watch-out below.

Watch out for

Worked example

Three messages go out: "abc" (length 3), "hi" (length 2), and "ping" (length 4). To keep it readable the visual uses a one-byte length header. They arrive in four chunks that deliberately ignore the frame boundaries:

ChunkBytesWhat the receiver does
103 a bBuffer holds 3 bytes. Header says len 3, but only 2 payload bytes are present — wait for more.
2c 02 hThe c completes frame 1 → emit "abc". Then 02 h starts frame 2; 1 of 2 payload bytes — wait.
3i 04 p iThe i completes frame 2 → emit "hi". Then 04 p i starts frame 3; 2 of 4 payload bytes — wait.
4n gNow 4 payload bytes are buffered → emit "ping". Buffer is empty; all three messages recovered.

No chunk lined up with a frame. Between reads the buffer quietly held the dangling partial frame, and the length header told the receiver exactly how many more bytes to wait for. That is the whole trick.

Check yourself

Your handler does buffer.extend(chunk), reads one frame if a complete one is present, dispatches it, and returns — without the surrounding while loop. What goes wrong?