Skip to content

TCP Sticky Packet Issue

TCP is a byte-stream protocol with no concept of message boundaries. When the sender transmits multiple messages in quick succession, or when the receiver does not read data fast enough, multiple messages may be merged into a single receive — this is known as sticky packets (also called TCP packet coalescing).

Why Sticky Packets Occur

  • Sender side: The Nagle algorithm merges multiple small, closely timed send calls into a single large packet.
  • Receiver side: recv(n) reads at most n bytes at a time. When multiple messages accumulate in the buffer, one recv call may span across multiple messages.

UDP does not have sticky packets: UDP is message-oriented; each datagram has an independent boundary, and recvfrom reads exactly one complete datagram at a time.

When You Need to Solve This

  • Must solve: Real-time messaging, command/response protocols — you must know the boundary of each message.
  • Not necessary: File transfer — you only need to reassemble all the bytes at the end; message boundaries don’t matter.

Solution: Fixed-Length Header (struct)

struct.pack("i", n) packs the integer n into a fixed 4-byte representation. The receiver first reads 4 bytes to get the message length, then reads that many bytes of data, precisely splitting messages.

Basic Version (Length-Only Header)

Server

import socket
import struct

def run_server(host: str = "127.0.0.1", port: int = 9010) -> None:
    server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
    server.bind((host, port))
    server.listen(5)
    print(f"Server listening on {host}:{port}")

    conn, addr = server.accept()
    print(f"Client connected: {addr}")

    while True:
        raw_cmd = conn.recv(1024)
        if not raw_cmd:
            break
        cmd = raw_cmd.decode("utf-8")
        print(f"Received command: {cmd}")

        reply = f"Executed: {cmd}".encode("utf-8")

        # Send 4-byte length first, then the actual data
        conn.send(struct.pack("i", len(reply)))
        conn.sendall(reply)

    conn.close()
    server.close()

if __name__ == "__main__":
    run_server()

Client

import socket
import struct

def run_client(host: str = "127.0.0.1", port: int = 9010) -> None:
    with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as client:
        client.connect((host, port))

        commands = ["ls -la", "pwd", "date"]
        for cmd in commands:
            client.send(cmd.encode("utf-8"))

            # Read 4 bytes first to get the message length
            raw_len = client.recv(4)
            msg_len = struct.unpack("i", raw_len)[0]

            # Read in a loop by length to handle short recv reads
            data = b""
            while len(data) < msg_len:
                chunk = client.recv(min(1024, msg_len - len(data)))
                if not chunk:
                    break
                data += chunk

            print(f"Server reply: {data.decode('utf-8')}")

if __name__ == "__main__":
    run_client()

Advanced Version (JSON Header + Payload)

For complex scenarios (file transfer, multi-field metadata), the header can be designed as a JSON dictionary:

import socket
import struct
import json
from pathlib import Path

def send_with_header(conn: socket.socket, payload: bytes, metadata: dict) -> None:
    """Send: 4-byte header length + JSON header + actual data."""
    head_json = json.dumps(metadata, ensure_ascii=False).encode("utf-8")
    # Send header length first (4 bytes)
    conn.send(struct.pack("i", len(head_json)))
    # Then send the JSON header
    conn.send(head_json)
    # Finally send the actual data
    conn.sendall(payload)

def recv_with_header(conn: socket.socket) -> tuple[dict, bytes]:
    """Receive: read header length, then JSON header, then actual data."""
    raw = conn.recv(4)
    head_len = struct.unpack("i", raw)[0]
    head_json = conn.recv(head_len).decode("utf-8")
    metadata = json.loads(head_json)

    data_len = metadata["size"]
    data = b""
    while len(data) < data_len:
        chunk = conn.recv(min(4096, data_len - len(data)))
        if not chunk:
            break
        data += chunk
    return metadata, data

struct Format Quick Reference

import struct

# pack(fmt, value) → bytes,  unpack(fmt, bytes) → tuple
struct.pack("i", 12345)          # int  → 4 bytes
struct.pack("I", 12345)          # unsigned int → 4 bytes
struct.pack("q", 9_999_999_999)  # long long → 8 bytes (larger range)

tup = struct.unpack("i", struct.pack("i", 12345))
print(tup[0])   # 12345

# calcsize: query the byte size of a format
print(struct.calcsize("i"))   # 4
print(struct.calcsize("q"))   # 8

"i" has a range of approximately ±2.1 billion. For large file transfers, use "q" (±9.2 × 10^18) instead.

Last updated on