Skip to main content

Welcome to a Grasp lesson

Create a free account to keep studying and create your own study path.

Lesson 32

Length-Prefixed Framing for TCP

Recommended resources

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.

Length-Prefixed Message Framing
This diagram shows the core idea of framing. A structured message (1) is converted into a sequence of bytes (2). We then calculate the length of this byte sequence and prepend it, creating a self-contained, framed message (3) ready for transmission.

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:

  1. 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.
  2. Decode the total message length from the header bytes.
  3. Calculate the remaining length to read: payloadLength = TotalLength - HeaderSize.
  4. Use io.ReadFull to read exactly payloadLength more bytes.
  5. 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:

  1. Check if len(data) is at least HeaderSize. If not, we don't have a full header yet. Return (0, nil, nil) to ask the scanner to read more data.
  2. If we have a header, parse the totalLength from it.
  3. Check if len(data) is at least totalLength. If not, we don't have the full message yet. Return (0, nil, nil).
  4. If we have the full message, return (totalLength, data[:totalLength], nil). This tells the scanner to advance its buffer by totalLength and return the slice data[: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.ReadFull is the fundamental tool for reading an exact number of bytes from a stream in Go.
  • An Encode/Decode pair using io.ReadFull and encoding/binary forms a complete, reliable framing layer.
  • For more advanced use cases, bufio.Scanner with a custom SplitFunc offers 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.