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.
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.
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.
| Property | Length-prefix framing |
|---|---|
| Per-frame overhead | Fixed header, e.g. 4 bytes per message regardless of size |
| Receiver work per byte | O(1) — read the length, jump ahead; no scanning for a delimiter |
| Latency | You must buffer the whole frame before dispatching it; a big frame can’t be handled until its last byte arrives |
| vs. delimiter framing | Delimiters (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 frames | Fixed 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.
recv() equals one message. This is the number-one framing bug. A read can return a partial message, or several messages stuck together. Never treat a single read as a single message.>I); a sender and receiver that disagree on endianness will read garbage lengths.sendall, not send — send may write fewer bytes than you handed it, truncating your frame on the wire.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:
| Chunk | Bytes | What the receiver does |
|---|---|---|
| 1 | 03 a b | Buffer holds 3 bytes. Header says len 3, but only 2 payload bytes are present — wait for more. |
| 2 | c 02 h | The c completes frame 1 → emit "abc". Then 02 h starts frame 2; 1 of 2 payload bytes — wait. |
| 3 | i 04 p i | The i completes frame 2 → emit "hi". Then 04 p i starts frame 3; 2 of 4 payload bytes — wait. |
| 4 | n g | Now 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.
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?