Protocol Design & Serialization

Binary Protocols, struct, MessagePack, Protocol Buffers, gRPC

Introduction

When HTTP and JSON are too slow or too verbose, engineers design custom binary protocols. Financial trading systems, game engines, IoT devices, and high-frequency data pipelines all use binary serialization for its speed and compactness. This lesson covers designing a binary protocol with Python's struct module, then introduces Protocol Buffers and MessagePack as production-grade alternatives.

Custom Binary Protocol Design

A binary protocol encodes data as raw bytes instead of text. This eliminates text-parsing overhead and produces significantly smaller payloads.

Message Frame Layout
Magic
2 bytes
Version
1 byte
Message Type
1 byte
Payload Length
4 bytes
Checksum
4 bytes
Payload
N bytes
Total fixed header: 12 bytes + variable payload

struct-based Protocol (Pack)

# binary_protocol.py
import struct
import zlib
from dataclasses import dataclass
from enum import IntEnum

# Protocol constants
MAGIC = 0x4243      # 'BC' for ByteCode
VERSION = 1

class MessageType(IntEnum):
    PING = 1
    PONG = 2
    DATA = 3
    ERROR = 4

# Header format: big-endian, 12 bytes total
# ! = network byte order (big-endian)
# H = unsigned short (2 bytes) , magic
# B = unsigned char  (1 byte)  , version
# B = unsigned char  (1 byte)  , message type
# I = unsigned int   (4 bytes) , payload length
# I = unsigned int   (4 bytes) , checksum
HEADER_FORMAT = "!HBBII"
HEADER_SIZE = struct.calcsize(HEADER_FORMAT)   # = 12 bytes

@dataclass
class Message:
    msg_type: MessageType
    payload: bytes

def encode_message(msg: Message) -> bytes:
    """Encode a Message into wire-format bytes."""
    checksum = zlib.crc32(msg.payload) & 0xFFFFFFFF
    header = struct.pack(
        HEADER_FORMAT,
        MAGIC,
        VERSION,
        int(msg.msg_type),
        len(msg.payload),
        checksum,
    )
    return header + msg.payload

# Encode a DATA message
payload = b'{"sensor": "temp01", "value": 23.5, "unit": "C"}'
msg = Message(msg_type=MessageType.DATA, payload=payload)
wire_bytes = encode_message(msg)

print(f"Payload (JSON text): {len(payload)} bytes")
print(f"Wire frame (binary): {len(wire_bytes)} bytes (header={HEADER_SIZE})")
print(f"Header hex: {wire_bytes[:HEADER_SIZE].hex()}")
print(f"Full frame hex: {wire_bytes.hex()[:60]}...")
Expected Output:
Payload (JSON text): 48 bytes
Wire frame (binary): 60 bytes (header=12)
Header hex: 42430103000000302730c075
Full frame hex: 42430103000000302730c0757b2273656e736f72223a202274656d703031...

struct-based Protocol (Unpack)

# binary_protocol_decode.py, continued
def decode_message(data: bytes) -> Message:
    """Decode wire-format bytes back into a Message. Raises on error."""
    if len(data) < HEADER_SIZE:
        raise ValueError(f"Too short: {len(data)} < {HEADER_SIZE}")

    magic, version, msg_type, payload_len, checksum = struct.unpack(
        HEADER_FORMAT, data[:HEADER_SIZE]
    )

    if magic != MAGIC:
        raise ValueError(f"Invalid magic: 0x{magic:04X} (expected 0x{MAGIC:04X})")
    if version != VERSION:
        raise ValueError(f"Unsupported version: {version}")

    payload = data[HEADER_SIZE:HEADER_SIZE + payload_len]
    actual_checksum = zlib.crc32(payload) & 0xFFFFFFFF

    if actual_checksum != checksum:
        raise ValueError(f"Checksum mismatch: {actual_checksum} != {checksum}")

    return Message(msg_type=MessageType(msg_type), payload=payload)

# Round-trip: encode then decode
decoded = decode_message(wire_bytes)
print(f"\nDecoded message type: {decoded.msg_type.name}")
print(f"Decoded payload: {decoded.payload.decode()}")

# Tampered frame, checksum catches it
tampered = bytearray(wire_bytes)
tampered[15] ^= 0xFF   # flip a bit in the payload
try:
    decode_message(bytes(tampered))
except ValueError as e:
    print(f"\nTampered frame rejected: {e}")
Expected Output:
Decoded message type: DATA
Decoded payload: {"sensor": "temp01", "value": 23.5, "unit": "C"}

Tampered frame rejected: Checksum mismatch: 1604661359 != 657506421

MessagePack, Binary JSON

MessagePack serializes the same data as JSON but in binary, typically 30–50% smaller and 2–10× faster to serialize/deserialize.

Install: pip install msgpack

# msgpack_demo.py
import json
import msgpack
import time

data = {
    "user_id": 12345,
    "name": "Alice Johnson",
    "scores": [95, 87, 92, 78, 88],
    "metadata": {"role": "admin", "active": True, "level": 3},
}

# JSON serialization
json_bytes = json.dumps(data).encode("utf-8")

# MessagePack serialization
msgpack_bytes = msgpack.packb(data, use_bin_type=True)

print(f"JSON size:      {len(json_bytes)} bytes  → {json_bytes.decode()[:60]}...")
print(f"MessagePack:    {len(msgpack_bytes)} bytes  → {msgpack_bytes.hex()[:40]}...")
print(f"Size reduction: {(1 - len(msgpack_bytes)/len(json_bytes))*100:.1f}%")

# Deserialization
decoded = msgpack.unpackb(msgpack_bytes, raw=False)
print(f"\nDeserialized correctly: {decoded == data}")

# Speed comparison (1,000,000 iterations)
N = 1_000_000
start = time.perf_counter()
for _ in range(N):
    json.dumps(data)
json_time = time.perf_counter() - start

start = time.perf_counter()
for _ in range(N):
    msgpack.packb(data, use_bin_type=True)
msgpack_time = time.perf_counter() - start

print(f"\nJSON serialize 1M×:    {json_time:.3f}s")
print(f"MessagePack 1M×:       {msgpack_time:.3f}s")
print(f"MessagePack is {json_time/msgpack_time:.1f}× faster")
Expected Output:
JSON size:      134 bytes  → {"user_id": 12345, "name": "Alice Johnson", "scores": [95, 8...
MessagePack:    80 bytes  → 84a7757365725f6964cd3039a46e616d65ad416c...
Size reduction: 40.3%

Deserialized correctly: True

JSON serialize 1M×:    1.686s
MessagePack 1M×:       0.738s
MessagePack is 2.3× faster

Serialization Format Comparison

FormatSizeSpeedSchemaHuman ReadableBest For
JSONLargeBaselineOptional✅ YesAPIs, config, debugging
MessagePack~40% smaller2–3× fasterNone❌ BinaryHigh-throughput APIs
Protocol BuffersSmallest5–10× fasterRequired (.proto)❌ BinaryMicroservices, gRPC
gRPCSmallestFastestRequired (.proto)❌ BinaryInternal service RPCs
Custom BinaryOptimalFastestCustom❌ BinaryIoT, trading, gaming

Key Takeaways

  • Binary protocols are faster and smaller, eliminate text parsing overhead; critical for latency-sensitive systems
  • struct defines wire formats, use !HBBII format strings; ! ensures network byte order (big-endian)
  • Always include a checksum, CRC32 catches corruption and tampering in transit
  • MessagePack = binary JSON drop-in, ~40% smaller, 2-3× faster, no schema required
  • Protocol Buffers require a schema, more work upfront but 5-10× faster than JSON with guaranteed type safety
  • gRPC is built on Protobuf, adds streaming, bidirectional communication, and code generation for clients/servers
What's Next?

Lesson 9 puts TLS center stage, the protocol securing nearly every connection on the internet. You'll implement TLS in Python and explore mutual TLS (mTLS) for service-to-service authentication.

  • TLS handshake, step-by-step breakdown of what happens before the first byte of data
  • Self-signed certificates, generating test certs with Python + OpenSSL
  • Mutual TLS (mTLS), both client and server present certificates
  • Certificate pinning, rejecting valid but unexpected certificates