In the previous post, we successfully performed the TLS handshake and sent our 24-byte connection preface. To the server, we now look like a valid HTTP/2 client. But as soon as that preface is sent, the server starts talking back in a language we haven’t yet taught our Go code to understand.

While HTTP/1.1 communicated in lines of text separated by newlines, HTTP/2 communicates in frames. Every single interaction from here on out (settings, headers, data, and keepalive pings) happens inside of a frame. To progress, we need to build a parser that can slice into these binary packets and make sense of the bits inside.

The Anatomy of a Frame

According to the specification, every HTTP/2 frame begins with a fixed 9-byte header. This header is the roadmap for the rest of the payload. It tells us exactly how much data follows and how we should interpret it.

The 9-byte header is laid out precisely. Using a packet visualization, we can see how the bits are packed together:

packet-beta
0-23: "Length (24 bits)"
24-31: "Type (8 bits)"
32-39: "Flags (8 bits)"
40: "R (Reserved)"
41-71: "Stream Identifier (31 bits)"
  

The header fields serve specific purposes:

  • Length: The size of the frame payload (up to 16,383 bytes by default).
  • Type: The specific purpose of the frame (e.g., 0x0 for DATA, 0x1 for HEADERS).
  • Flags: Boolean modifiers that change how the frame is processed.
  • Stream Identifier: A unique ID that links the frame to a specific request-response lifecycle.

The first bit of the Stream Identifier is reserved (R) and must remain zero. This is a common pattern in binary protocols to ensure alignment or save space for future protocol versions.

Modeling the Header in Go

To handle this in Go, we need a structure that mirrors the spec. While the length and stream ID are 24 and 31 bits respectively, we use uint32 to store them and handle the bit-masking manually during the read.

type FrameHeader struct {
    Length   uint32
    Type     uint8
    Flags    uint8
    StreamID uint32
}

The challenge here is that Go’s encoding/binary package doesn’t have a direct way to read a 24-bit integer. We have to read three bytes and manually shift them into place. We are no longer working with strings; we are working with bitwise operators.

Thinking in Bits

Before we write the parser, we need to get comfortable with Bit Shifting. In Go, we use the << (left shift) and | (bitwise OR) operators to “stitch” separate bytes into a single number.

Think of it like a conveyor belt. To turn three separate bytes into one 24-bit integer, we take the first byte and slide it 16 places to the left, move the second byte 8 places to the left, and then lay them all on top of each other using the OR operator.

In Go, uint32(data[0]) << 16 tells the compiler to take an 8-bit byte, treat it as a 32-bit number, and move its value to the “high” end of the bucket. HTTP/2 always uses network byte order (big-endian). Go’s binary.BigEndian makes this explicit when reconstructing integers.

Bitwise operations take some getting used to, so it’s worth spending time with them if this feels unfamiliar. I recommend Chapter 27: Enum, Iota & Bitmask from Practical Go Lessons.

Parsing the Bytes

When the server responds, we read the first 9 bytes from our tls.Conn. We then use bit-shifting to reconstruct the fields. For the Stream Identifier, we use binary.BigEndian.Uint32 and then mask out the first bit.

We also need to handle the Reserved (R) bit in the Stream Identifier. Even though we read 4 bytes (32 bits), the spec says the first bit must be ignored. We use the bitwise AND operator with 0x7FFFFFFF, which is a binary 0 followed by thirty-one 1s—to “mask out” that first bit and ensure our ID is always interpreted correctly.

func ReadFrameHeader(data []byte) FrameHeader {
    // Length is 24 bits: first 3 bytes shifted and OR'd
    length := uint32(data[0])<<16 | uint32(data[1])<<8 | uint32(data[2])
    
    // Type and Flags are single bytes
    fType := data[3]
    flags := data[4]
    
    // Stream ID is 31 bits. We read 4 bytes starting at offset 5, 
    // then mask out the reserved bit (0x7FFFFFFF)
    streamID := binary.BigEndian.Uint32(data[5:9]) & 0x7FFFFFFF

    return FrameHeader{
        Length:   length,
        Type:     fType,
        Flags:    flags,
        StreamID: streamID,
    }
}

The SETTINGS Frame

Immediately after receiving our preface, the server will send a SETTINGS frame (Type 0x04). This is the protocol’s way of establishing the rules of the road. It defines limits like how many concurrent streams we can open or how large our header table can be.

The payload of a SETTINGS frame consists of a series of 6-byte parameters:

packet-beta
title SETTINGS Parameter (6 Bytes)
0-15: "Identifier (16 bits)"
16-47: "Value (32 bits)"
  

For now, the most important thing is that we simply acknowledge these settings. HTTP/2 requires that when you receive a SETTINGS frame, the client must send back a SETTINGS frame with the ACK flag set (bit 0x1). If you don’t, the server will eventually assume the client is unresponsive and close the connection.

Putting it into Practice

We can now update our client to read the header, read the payload based on the length we just parsed, and log what we found. This is where the binary nature of the protocol becomes visible in your terminal.

full parser.go (click to expand)View on GitHub
package main

import (
	"encoding/binary"
	"fmt"
	"io"
)

// FrameHeader represents the 9-byte fixed header of every HTTP/2 frame.
type FrameHeader struct {
	Length   uint32
	Type     uint8
	Flags    uint8
	StreamID uint32
}

// Frame represents a complete HTTP/2 frame including its payload.
type Frame struct {
	Header  FrameHeader
	Payload []byte
}

// ReadFrame reads a header and then the corresponding payload from the connection.
func ReadFrame(r io.Reader) (Frame, error) {
	// 1. Read the 9-byte header
	headerBuf := make([]byte, 9)
	_, err := io.ReadFull(r, headerBuf)
	if err != nil {
		return Frame{}, fmt.Errorf("reading header: %w", err)
	}

	// 2. Parse the header fields using bit-shifting
	header := FrameHeader{
		Length:   uint32(headerBuf[0])<<16 | uint32(headerBuf[1])<<8 | uint32(headerBuf[2]),
		Type:     headerBuf[3],
		Flags:    headerBuf[4],
		StreamID: binary.BigEndian.Uint32(headerBuf[5:9]) & 0x7FFFFFFF,
	}

	// 3. Read the payload based on the Length field
	payload := make([]byte, header.Length)
	if header.Length > 0 {
		_, err = io.ReadFull(r, payload)
		if err != nil {
			return Frame{}, fmt.Errorf("reading payload: %w", err)
		}
	}

	return Frame{Header: header, Payload: payload}, nil
}

Running this against kmcd.dev will show you exactly what Cloudflare or your server expects. You’ll see a Type: 4 frame with a specific length. This is the first time our code is truly listening to an HTTP/2 server.

What’s Next?

We can now read the frame header and the raw payload, but the raw payload represents a HEADERS frame and we have no idea how to actually parse that yet. In the next post, we will tackle the most complex part of the HTTP/2 specification: HPACK.

We need to learn how to compress and decompress headers using a stateful table, which is the only way we will ever be able to send a real GET request and get a real response.

See all of the code mentioned in this article here:

go/client.go (click to expand)View on GitHub
package main

import (
	"crypto/tls"
	"fmt"
	"log"
)

const (
	// The "Magic" preface required by RFC 9113
	preface = "PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n"
	server  = "kmcd.dev:443"
)

func main() {
	// We configure TLS to specifically look for "h2" via ALPN
	config := &tls.Config{
		NextProtos: []string{"h2"},
	}

	// Dial the server and perform the handshake
	conn, err := tls.Dial("tcp", server, config)
	if err != nil {
		log.Fatalf("Failed to connect: %v", err)
	}
	defer conn.Close()

	// Ensure the server actually agreed to speak HTTP/2
	state := conn.ConnectionState()
	if state.NegotiatedProtocol != "h2" {
		log.Fatalf("Server did not negotiate HTTP/2: %s", state.NegotiatedProtocol)
	}

	fmt.Printf("Connected to %s using %s\n", server, state.NegotiatedProtocol)

	// Send the Connection Preface to initialize the H2 session
	_, err = conn.Write([]byte(preface))
	if err != nil {
		log.Fatalf("Failed to send preface: %v", err)
	}

	fmt.Println("Preface sent successfully. The connection is open.")

	frame, err := ReadFrame(conn)
	if err != nil {
		log.Fatalf("Failed to read server settings: %v", err)
	}

	// Check the Type on the Header field of our Frame struct
	if frame.Header.Type != 0x04 {
		log.Fatalf("Expected SETTINGS frame (0x04), got: %d", frame.Header.Type)
	}
	fmt.Printf("Received SETTINGS: %d bytes on Stream %d\n", frame.Header.Length, frame.Header.StreamID)

	// 2. Acknowledge the settings frame
	ackHeader := []byte{
		0x00, 0x00, 0x00, // Length: 0
		0x04,                   // Type: SETTINGS
		0x01,                   // Flags: ACK (0x01)
		0x00, 0x00, 0x00, 0x00, // Stream ID: 0 (Connection level)
	}

	if _, err = conn.Write(ackHeader); err != nil {
		log.Fatalf("Failed to send SETTINGS ACK: %v", err)
	}
	fmt.Println("Sent SETTINGS ACK.")
}
go/parser.go (click to expand)View on GitHub
package main

import (
	"encoding/binary"
	"fmt"
	"io"
)

// FrameHeader represents the 9-byte fixed header of every HTTP/2 frame.
type FrameHeader struct {
	Length   uint32
	Type     uint8
	Flags    uint8
	StreamID uint32
}

// Frame represents a complete HTTP/2 frame including its payload.
type Frame struct {
	Header  FrameHeader
	Payload []byte
}

// ReadFrame reads a header and then the corresponding payload from the connection.
func ReadFrame(r io.Reader) (Frame, error) {
	// 1. Read the 9-byte header
	headerBuf := make([]byte, 9)
	_, err := io.ReadFull(r, headerBuf)
	if err != nil {
		return Frame{}, fmt.Errorf("reading header: %w", err)
	}

	// 2. Parse the header fields using bit-shifting
	header := FrameHeader{
		Length:   uint32(headerBuf[0])<<16 | uint32(headerBuf[1])<<8 | uint32(headerBuf[2]),
		Type:     headerBuf[3],
		Flags:    headerBuf[4],
		StreamID: binary.BigEndian.Uint32(headerBuf[5:9]) & 0x7FFFFFFF,
	}

	// 3. Read the payload based on the Length field
	payload := make([]byte, header.Length)
	if header.Length > 0 {
		_, err = io.ReadFull(r, payload)
		if err != nil {
			return Frame{}, fmt.Errorf("reading payload: %w", err)
		}
	}

	return Frame{Header: header, Payload: payload}, nil
}