Hello! Welcome back to our project.
In our last lesson, we designed the blueprint for our broker's communication protocol. We defined a set of message types (ProduceRequest, ConsumeRequest, etc.) and established a standard message structure: a common Header followed by a message-specific body. Crucially, our header contains a TotalLength field. Today, we're going to put that field to work.
Your Goal for Today
Learning Outcome: Implement length-prefixed binary message framing for reliable TCP communication.
We'll bridge the gap between our abstract protocol design and the reality of network programming. You'll learn why simply calling conn.Read() is unreliable for message-based protocols and how to use length-prefixing to build a rock-solid framing layer that can pull complete, distinct messages from a continuous TCP stream.
This is a fundamental skill in systems programming. Getting it right ensures your network layer is robust and efficient; getting it wrong leads to data corruption, synchronization errors, and bugs that are notoriously difficult to track down.
1. The Challenge of Stream-Based Communication
TCP, the protocol we're using, doesn't think in terms of "messages". It provides a continuous, ordered stream of bytes. Imagine it as a river of data. When you send two messages back-to-back, they flow into the stream one after another.

When the receiver reads from this stream, it has no inherent way to know where one message ends and the next one begins. A single Read() call might return:
- A partial message.
- Exactly one full message.
- One full message plus a piece of the next one (a phenomenon sometimes called "sticky packets").
Our job is to implement a mechanism—a framing protocol—to solve this. By reading the TotalLength from our header first, we can tell the system exactly how many more bytes to read to get one complete message.
2. The Essential Tool: io.ReadFull
Go's standard library provides the perfect tool for this job: io.ReadFull. While a standard io.Reader's Read method is allowed to return fewer bytes than you asked for, io.ReadFull is stricter. It blocks until it has read exactly the number of bytes required to fill the buffer you provide, or it returns an error (like io.EOF if the connection closes prematurely).
This guarantee is the foundation of our framing implementation.
Let's look at a practical guide that implements a full encode/decode cycle using this principle.
How to Implement Custom Binary Protocols Over TCP in Go
The article 'How to Implement Custom Binary Protocols Over TCP in Go' provides a clear, step-by-step implementation of length-prefixed framing. We'll use its Decode and Encode functions as a model for our own.
Please read the sections 'Encoding Messages' and 'Decoding Messages'. Pay close attention to how the Decode function first reads a fixed-size header and then uses the length from that header with io.ReadFull to read the payload. Notice how the Encode function does the reverse, writing the header and then the payload.
3. Implementing the Framer
Now, let's apply this pattern to the protocol we designed in the last lesson. We'll create Encode and Decode functions within our internal/protocol package.
The Decode Function (Reading from the Stream)
The Decode function's job is to read from an io.Reader and return one complete message as a byte slice. It doesn't need to understand the content of the message body yet; it just needs to find the message's boundaries.
Based on the pattern from the article and our protocol design, the logic is:
- Read the fixed-size length prefix from the stream. In our design, the length is a
uint32(4 bytes), but it's part of a 5-byte header (MessageType+TotalLength). We'll read the whole 5-byte header at once. - Decode the total message length from the header bytes.
- Calculate the remaining length to read:
payloadLength = TotalLength - HeaderSize. - Use
io.ReadFullto read exactlypayloadLengthmore bytes. - Return the full message (header + payload bytes) for later processing.
Here is a possible implementation. Let's add this to a new file, internal/protocol/framing.go.
// file: internal/protocol/framing.go
package protocol
import (
"encoding/binary"
"io"
)
// In the last lesson, we defined our Header struct.
// Let's define a constant for its size on the wire.
// MessageType (uint8) is 1 byte, TotalLength (uint32) is 4 bytes.
const HeaderSize = 5
// Decode reads a full message frame from the reader.
// It returns the message type and the full message payload (excluding the header).
// The full message payload includes the fixed-size body struct AND any variable-length data.
func Decode(r io.Reader) (MessageType, []byte, error) {
headerBytes := make([]byte, HeaderSize)
if _, err := io.ReadFull(r, headerBytes); err != nil {
return 0, nil, err
}
msgType := MessageType(headerBytes[0])
// We use BigEndian to match the network standard.
totalLength := binary.BigEndian.Uint32(headerBytes[1:HeaderSize])
// The payload is the total length minus the header size.
payloadLength := totalLength - HeaderSize
// Guard against potential malformed packets or DoS attacks with huge length values.
// We'll use a sensible limit, e.g., 1MB. You can make this configurable.
const MaxPayloadSize = 1 << 20
if payloadLength > MaxPayloadSize {
return 0, nil, io.ErrUnexpectedEOF // Or a custom error
}
payload := make([]byte, payloadLength)
if _, err := io.ReadFull(r, payload); err != nil {
return 0, nil, err
}
return msgType, payload, nil
}
The Encode Function (Writing to the Stream)
The Encode function does the reverse. It takes a message type and a payload, constructs the header, and writes the header and payload to the stream.
// file: internal/protocol/framing.go (continued)
// Encode writes a full message frame to the writer.
func Encode(w io.Writer, msgType MessageType, payload []byte) error {
header := make([]byte, HeaderSize)
header[0] = byte(msgType)
totalLength := uint32(HeaderSize + len(payload))
binary.BigEndian.PutUint32(header[1:HeaderSize], totalLength)
// Write the header
if _, err := w.Write(header); err != nil {
return err
}
// Write the payload
if _, err := w.Write(payload); err != nil {
return err
}
return nil
}
Now, in our handleConnection loop, we can replace our simple text-based Read and Write with these framing functions to reliably exchange binary messages.
4. An Alternative Approach: bufio.Scanner
The io.ReadFull approach works perfectly, but it requires you to manage the read loop manually. Go offers a more elegant, higher-level abstraction for tokenizing streams: bufio.Scanner.
A Scanner can be configured with a custom "split function" that teaches it how to find the boundaries of a "token" (in our case, a message) in a stream. This approach is very robust and can be more efficient as the Scanner handles buffering internally.
The following video demonstrates this exact technique for the Language Server Protocol, which also uses length-prefixed framing (Content-Length:). The principles are identical to what we need for our binary protocol.
Learn By Building: Language Server Protocol
This video by TJ DeVries shows a powerful, idiomatic Go pattern for handling framed protocols using a custom bufio.Scanner. Watching this will give you an alternative, production-ready implementation strategy.
Watch the segment from 20:44 to 25:16. The key part is the implementation of the split function. Observe how it checks if enough data is available for the header, then for the full content, returning 0, nil, nil to signal 'not enough data yet'. This avoids blocking reads and lets the scanner manage the buffer.
The logic for a SplitFunc for our protocol would be:
- Check if
len(data)is at leastHeaderSize. If not, we don't have a full header yet. Return(0, nil, nil)to ask the scanner to read more data. - If we have a header, parse the
totalLengthfrom it. - Check if
len(data)is at leasttotalLength. If not, we don't have the full message yet. Return(0, nil, nil). - If we have the full message, return
(totalLength, data[:totalLength], nil). This tells the scanner to advance its buffer bytotalLengthand return the slicedata[:totalLength]as the token.
While we will stick with the io.ReadFull implementation for simplicity in our project, understanding the bufio.Scanner approach is excellent for your goal of achieving mastery in Go systems programming.
Conclusion
Today, you've implemented one of the most critical pieces of any network service: the framing layer. By moving from simple reads to a length-prefixed protocol, you've made our server's communication robust and reliable.
Key Takeaways:
- TCP provides a byte stream, not a message stream. It's our responsibility to define message boundaries (framing).
- Length-prefixing is a common and effective framing strategy: send the length, then the data.
io.ReadFullis the fundamental tool for reading an exact number of bytes from a stream in Go.- An
Encode/Decodepair usingio.ReadFullandencoding/binaryforms a complete, reliable framing layer. - For more advanced use cases,
bufio.Scannerwith a customSplitFuncoffers a powerful and idiomatic alternative for tokenizing streams.
Next Up
We can now reliably extract a byte slice for each message. But a byte slice isn't very useful on its own. In the next lesson, we will write the code that takes this payload and decodes it into the specific Go structs we designed (ProduceRequestBody, ConsumeResponseBody, etc.). We will dive deep into the encoding/binary package to serialize and deserialize our message structs.