Binary protocol design must first define how the receiver obtains clear, bounded, and verifiable messages from bytes. Compact encoding is only one aspect; one must also specify field meanings, repetition handling, unknown values, length limits, state transitions, and error behaviors. Text can also have strict framing, and binary may be larger than some text; one cannot derive quality solely from form.
Layer
What must be explicit
Common confusion
Transport
TCP byte stream, UDP datagram, or existing stream protocol
Mistaking a single read for a complete message
Framing
Where a message starts, when it is complete, and its maximum length
Having only a payload schema without message boundaries
Field Encoding
Type, byte order, length, and integer representation
Equating directly with memory structures
Semantics
Units, ranges, defaults, states, errors
Assuming a request is executable just because bytes can be parsed
Evolution
Unknown fields, capabilities, and critical extension rules
Unconditionally ignoring all unknown content
Framing: Length prefixes also require a complete contract
TCP does not preserve application write boundaries. Two send calls may appear in a single read, and one send may be completed by multiple read operations. UDP preserves datagram boundaries, but one still needs to determine if the application message is complete, whether fragmentation or multiple records are allowed, and if the receive buffer causes truncation.
Method
Advantages
Rules that must be supplemented
Fixed length
Boundaries are directly calculable
Version changes, padding, and waste
Length prefix
Supports arbitrary payload bytes
Whether length includes header, upper limit, overflow, truncation
Delimiter
Suitable for streaming text or records
Escaping, cross-read matching, longest record
Chunked/End frame
No need to know total length in advance
Chunk limits, end state, total count, and timeout
The empty line in HTTP headers only delimits the end of the headers; it does not independently determine the length of the entire response body; SMTP DATA also relies on dot-stuffing escaping. One cannot treat these local mechanisms as universal framing that requires no other state.
The length field is not a trusted memory allocation instruction. The receiver should first validate the fixed header and length upper limit, then receive incrementally; for long-lived connections, one should also limit the number of incomplete messages, total buffer size, assembly time, and decompressed size. Limiting a single frame to 1 MiB still does not prevent ten thousand connections from each holding half a frame.
An instructional protocol for derivation
The following defines a small protocol used only to illustrate parsing boundaries; it is not an existing standard. It runs on a reliable, ordered byte stream, using a 12-byte fixed header and a TLV body; an empty body is allowed, and all multi-byte integers are big-endian.
Field
Definition
Magic
Two bytes KB, used to identify the format, not for authentication
Version
Only version 1 is accepted; other versions fail
Flags
Must be 0 currently, to avoid silently ignoring unknown critical semantics
Body length
uint32, in bytes, excluding the fixed header, maximum 4096
Request ID
uint32, used to correlate requests within this connection; not inherently a business idempotency key
TLV
uint8 type + uint16 length + value
The most significant bit of the TLV type indicates a critical field, and the lower 7 bits are the field ID. In this example, known ID 1 is a strict UTF-8 name, and ID 2 is a 4-byte uint32 quantity; known IDs are not allowed to repeat, other optional fields are skipped, and unknown critical fields are rejected. Missing known fields are allowed at the syntax level, but business operations may require them to be mandatory.
This "critical bit" is a rule actively defined by this protocol, not common to all TLV formats. Readers only need to know the length to skip optional values, but this does not imply understanding their semantics, nor does it guarantee that forwarding remains correct after discarding them.
Incremental parsing: Separate waiting, failure, and normal completion
The following parser limits body length and validates TLVs frame by frame. It receives chunks provided by the caller, requires the upper layer to feed bounded reads, and is responsible for connection-level budgets and timeouts; the example is not a complete server.
importstructHEADER=struct.Struct('>2sBBII')MAX_BODY=4096defparse_tlvs(body):pos, values=0, {}whilepos<len(body):iflen(body)-pos<3:raiseValueError('truncated TLV header')kind, size=struct.unpack_from('>BH',body,pos)pos+=3ifsize>len(body)-pos:raiseValueError('TLV exceeds body')value=body[pos:pos+size]pos+=sizefield_id=kind&0x7fiffield_idnotin(1,2):ifkind&0x80:raiseValueError('unknown critical field')continueiffield_idinvalues:raiseValueError('duplicate known field')iffield_id==1:values[1]=value.decode('utf-8',errors='strict')else:ifsize!=4:raiseValueError('quantity must be uint32')values[2]=struct.unpack('>I',value)[0]returnvaluesclassDecoder:def__init__(self):self.buffer=bytearray()deffeed(self,chunk):self.buffer.extend(chunk)messages=[]whilelen(self.buffer)>=HEADER.size:magic, version, flags, size, request_id=HEADER.unpack_from(self.buffer)ifmagic!=b'KB'orversion!=1orflags!=0orsize>MAX_BODY:raiseValueError('invalid header')end=HEADER.size+sizeiflen(self.buffer)<end:breakfields=parse_tlvs(bytes(self.buffer[HEADER.size:end]))delself.buffer[:end]messages.append((request_id,fields))returnmessagesdefeof(self):ifself.buffer:raiseValueError('truncated frame')deftlv(kind,value):returnstruct.pack('>BH',kind,len(value))+valuedefframe(body,request_id=7):returnHEADER.pack(b'KB',1,0,len(body),request_id)+bodybody=tlv(1,'温度'.encode())+tlv(2,struct.pack('>I',300))wire=frame(body)assertlen(body)==16andlen(wire)==28expected=[(7,{1:'温度',2:300})]# Exhaust every two-part read boundary, including empty reads and complete reads.
forsplitinrange(len(wire)+1):decoder=Decoder()assertdecoder.feed(wire[:split])+decoder.feed(wire[split:])==expecteddecoder.eof()# Two frames concatenated must also be consumed frame by frame.
assertDecoder().feed(wire+wire)==expected+expectedassertDecoder().feed(frame(body+tlv(3,b'optional')))==expectedbad=[frame(body+tlv(0x83,b'critical')),frame(body+tlv(1,b'duplicate')),frame(tlv(1,b'\xff')),frame(tlv(2,b'\x01')),frame(b'\x01\x00\x04x'),HEADER.pack(b'KB',1,0,MAX_BODY+1,7),wire[:-1],]forcandidateinbad:decoder=Decoder()try:decoder.feed(candidate)decoder.eof()exceptValueError:passelse:raiseAssertionError('invalid input accepted')print('28 B message: all split boundaries, frame concatenation, optional fields, and 7 types of rejection passed')
After a parsing error, this example requires the caller to terminate decoding of that stream, rather than continuing to guess if the next KB is a frame header, because that byte sequence might also legally appear within the payload. If resynchronization in noise is indeed needed, synchronization, integrity, and recovery rules should be designed separately.
Production implementations must also consider the cost of moving buffers byte by byte, how to deliver messages parsed before an exception, memory ownership, and asynchronous cancellation. Zero-copy does not exempt one from lifecycle constraints: retaining a small view may prevent the entire large buffer from being released.
Is half a header a wait or an error?
At11 bytes, EOF turns waiting into truncation. At17, the first frame is complete even before the second arrives. Oversize mode rejects at12 bytes without waiting for4097 body bytes.
Preparing the visual
Is half a header a wait or an error?
Insufficient bytes can wait before EOF but become truncation afterward. An oversized length is rejected at a complete header without waiting or allocating its body.
Integer Encoding: Protobuf varint and QUIC varint are different
Encoding
Boundary Rules
Value 300
uint32 big-endian
Fixed 4 B, high byte first
00 00 01 2c
uint32 little-endian
Fixed 4 B, low byte first
2c 01 00 00
Protobuf unsigned varint
7-bit value per byte, MSB indicates subsequent bytes
ac 02
QUIC variable-length integer
First byte's top 2 bits determine total length 1/2/4/8 B
41 2c
Protobuf varint places the least significant group first, with uint64 taking up to 10 B; QUIC encodes in network byte order outside the length marker, with a maximum of 62 bits. QUIC's general integer encoding allows some non-shortest representations, but specific fields like frame type have separate shortest encoding requirements; it cannot be generalized as "all varints must be shortest". QUIC §16
defuvarint(n):ifnot0<=n<1<<64:raiseValueError('not uint64')result=bytearray()whilen>=128:result.append((n&127)|128)n>>=7result.append(n)returnbytes(result)defquic_integer(n):forlength,bits,prefixin[(1,6,0),(2,14,1),(4,30,2),(8,62,3)]:if0<=n<1<<bits:return(n|(prefix<<(8*length-2))).to_bytes(length,'big')raiseValueError('not uint62')assertuvarint(300).hex()=='ac02'assertquic_integer(300).hex()=='412c'assertlen(uvarint(127))==1andlen(uvarint(128))==2assertlen(quic_integer(63))==1andlen(quic_integer(64))==2assertlen(uvarint(2**64-1))==10assertlen(quic_integer(2**62-1))==8# ZigZag maps signed numbers with small absolute values to small unsigned numbers.
zigzag=lambdan:2*nifn>=0else-2*n-1assert[zigzag(n)fornin[0,-1,1,-2]]==[0,1,2,3]assertuvarint(zigzag(-2))==b'\x03'print('Two types of varints, boundaries, and ZigZag mapping passed')
ZigZag is a value mapping, followed by varint encoding. Protobuf's sint32/sint64 use it; negative numbers for int32/int64 follow another rule, where negatives may take 10 B. One cannot interchange declarations just because their wire type is 0.
Why do two varints encode300 differently?
Inspect63/64 where QUIC grows to2 bytes, then127/128 where Protobuf grows. These are different length rules. At300 compare group order in ac02 and412c.
Preparing the visual
Why do two varints encode300 differently?
Group order and length signals differ. Protobuf sends low7-bit groups first; QUIC announces total length and keeps big-endian value order.
Protobuf: Wire type gives boundaries, schema gives semantics
syntax = "proto3";
message Person {
string name = 1;
uint32 age = 2;
}
An encoding for name="Li", age=300 is 0a 02 4c 69 10 ac 02: the tag for field 1 is (1 << 3) | 2 = 0a, followed by length 2 and UTF-8; the tag for field 2 is 10, followed by varint ac 02. Field names do not appear on the wire, and age units and constraints are not interpreted by the tag itself.
Wire type
Boundary
Possible semantics
0
varint end bit
int, uint, sint, bool, enum
1
8 B
fixed64, double, etc.
2
Length prefix
string, bytes, nested messages, packed repeated
5
4 B
fixed32, float, etc.
3 / 4
Old group start/end
Deprecated group expression
Therefore, Protobuf is not a uniform three-segment TLV where each field explicitly carries an independent length. fixed32/fixed64 etc. use little-endian, which differs from the common big-endian of TCP/IP headers; variable-length integers should be understood according to their own bit-group rules. Protobuf Encoding
Retention of unknown fields, field presence, and JSON conversion behavior depend on the specific runtime and interface. Reading a binary message into an object and then manually copying only known fields may lose unknown content that could otherwise be retained; wire compatibility does not mean all intermediate processing is lossless. Proto3 Unknown Fields
Byte order, normalization, and integrity determine separately
New protocols should explicitly specify field width, sign, byte order, floating-point representation, text encoding, and units, rather than "just pick CPU native". Sending C structs directly introduces padding, alignment, and ABI differences; CPU allowing unaligned access does not imply the other end's language and hardware also allow it.
Signatures, hashes, and cache keys also require defining whether they bind to raw bytes or normalized semantics. Two equivalent objects do not imply their serialized bytes are unique. If a unique representation is needed, rules for field order, duplicate keys, floating-point NaN, and shortest number encoding should be explicit; CBOR's deterministic encoding is an example of explicitly writing out these requirements, rather than "all CBOR is naturally normalized". CBOR Deterministic Encoding
CRC or checksums mainly detect unexpected corruption under applicable failure models; they cannot prove the message comes from a trusted peer; authentication requires MAC, AEAD, or signatures and key trust. Compression also requires specifying decompression limits; a small original frame does not mean the resource overhead after expansion is small.
The criterion for design completion is that failure inputs also have deterministic behavior
Input or State
Result to be specified
Half header, half body
Wait; explicit failure after EOF or timeout
Overly long declaration, integer overflow
Reject before allocation or addition overflow
Unknown optional/critical fields
Skip or fail according to explicit rules
Duplicate fields
Rejection, merging, or priority rules must be unique
Illegal UTF-8, excessive nesting
Explicitly accept range and budget
Messages not allowed in current state
Protocol error or specified recovery
Retrying the same business request
Effect determined by business idempotency contract, not guaranteed by framing
Prioritize testing truncation, concatenation, boundary lengths, unknown critical fields, duplicate values, and cross-language vectors, then proceed to fuzzing and resource budget testing. Fault tolerance should be explicitly defined interoperable behavior, not each implementation guessing to accept different inputs. Maintaining Robust Protocols