Under the Hood: Kafka Wire Protocol, how bytes move between your app and the broker.
If you’ve used Apache Kafka, you’ve likely interacted with it through high-level libraries in Python, Go, or Java. But what actually happens when your application “produces” a message?
While solving the “Build Your Own Kafka” challenge on CodeCrafters, I had to move past the abstractions and dive into the Kafka Wire Protocol: the low-level binary language that governs all broker-client and broker-broker communication.
What is a Wire Protocol?#
A wire protocol is the specification that defines how data is formatted and transmitted “over the wire” between systems. It’s the language that clients and servers speak to each other.
Kafka uses a binary protocol over TCP. The term “wire protocol” has its roots in the early days of telecommunications. The “wire” originally referred to literal telegraph wires and later telephone lines that connected computers.
Kafka Wire Protocol#
Kafka uses its own custom binary protocol for communication, which is designed for high performance and efficiency. Every byte counts, and the compact representation allows Kafka to achieve the high throughput it’s known for.
Some of the key components/characteristics of the protocol:
- Binary Format: Optimised format for machines to communicate.
- Request-Response Model: Communication follows a standard cycle where the client initiates a request and the broker provides a response.
- Stateless: Each request is self-contained, carrying all the metadata required for the broker to process it.
- Versioned: As Kafka evolves, its APIs do too. Every request includes a version number to ensure backward compatibility.
In addition to this, Kafka’s wire protocol is built on a set of fundamental data types Protocol Primitive Types like fixed-size types like BOOLEAN, INTs, and FLOAT64, variable-length types like STRING, BYTES, and their nullable or compact variants, and complex types like ARRAYS and RECORDS. Encoding relies on big-endian order, variable-length encoding.
Before diving into the details, let’s understand how Kafka The Communication Lifecycle.
Anatomy of Kafka Message#
Every interaction is wrapped in a request-response pattern “frame.” Whether you are producing a message or fetching metadata, the structure remains consistent. A client sends a request to a broker, and the broker sends back a response.
Relevant kafka docs
Let’s break down what a request looks like:
1. Request Structure#
The COMPLETE structure of a Request Frame sent over the wire looks like this:
A request consists of a size prefix, a header for metadata, and a body for the payload.
┌──────────────┬─────────────────┬──────────────┐
│ Message Size │ Request Header │ Request Body │
│ (4 bytes) │ (variable) │ (variable) │
└──────────────┴─────────────────┴──────────────┘
Message Size: Before the request header comes the request frame, which includes, a 4-byte integer indicating the total size of the request (excluding these 4 bytes).
Request Header: Contains metadata about the request which includes
API Key: A 16-bit integer identifying which API is being called (e.g., Produce, Fetch, Metadata) List
API Version: The version of the API being used, allowing for backward compatibility.
Correlation ID: A client-generated ID that links requests to responses.
Client ID: An optional string identifying the client application.
What a minimal request header looks like in terms of its binary structure: ┌─────────────┬─────────────┬──────────────────┬───────────────┐ │ API Key │ API Version │ Correlation ID │ Client ID │ │ (2 bytes) │ (2 bytes) │ (4 bytes) │ (variable) │ └─────────────┴─────────────┴──────────────────┴───────────────┘
Request Body: Contains the actual data specific to the API being called.
2. Response Structure#
The response follows a similar pattern, ensuring the client can decode the results.
┌──────────────┬─────────────────┬───────────────┐
│ Message Size │ Response Header │ Response Body │
│ (4 bytes) │ (variable) │ (variable) │
└──────────────┴─────────────────┴───────────────┘
Response Header: This has correlation id. Value of correlation id is same as what was sent in response. The correlation ID in the response matches the correlation ID from the request. This allows clients to map responses back to their originating requests, even when multiple requests are in flight.`
What a **response header** looks like in terms of its binary structure: ┌────────────────┬────────────────┐ │ Correlation ID │ Tagged Fields │ │ (4 bytes) │ (variable) │ └────────────────┴────────────────┘Response Body: Server send’s response payload as per api key and version which has been sent in request.
Typical Response Body Structure: ┌───────────────┬──────────────┬────────────────────┐ │ Throttle Time │ Error Code │ API Specific Data │ │ (int32 ms) │ (int16) │ (variable) │ └───────────────┴──────────────┴────────────────────┘Error Codes: When a client receives a non-zero error code, it needs to handle it appropriately—refresh metadata, retry the request, or report an error to the application. Kafka uses standardized error codes across all APIs List
Real-World Example: CreateTopics API#
To see this in action, let’s look at the CreateTopics API (Key 19, Version: 4). When you request a new topic named “orders,” the binary body contains the topic name, partition count, and replication factor. The broker responds with a success code (0) or a specific error (like 36 for TOPIC_ALREADY_EXISTS).
This is the Request structure of CreateTopics
CreateTopics Request (Version: 7) => [topics] timeout_ms validate_only _tagged_fields
topics => name num_partitions replication_factor [assignments] [configs] _tagged_fields
name => COMPACT_STRING
num_partitions => INT32
replication_factor => INT16
assignments => partition_index [broker_ids] _tagged_fields
partition_index => INT32
broker_ids => INT32
configs => name value _tagged_fields
name => COMPACT_STRING
value => COMPACT_NULLABLE_STRING
timeout_ms => INT32
validate_only => BOOLEAN
This is the Response structure of CreateTopics
CreateTopics Response (Version: 7) => throttle_time_ms [topics] _tagged_fields
throttle_time_ms => INT32
topics => name topic_id error_code error_message num_partitions replication_factor [configs] _tagged_fields
name => COMPACT_STRING
topic_id => UUID
error_code => INT16
error_message => COMPACT_NULLABLE_STRING
num_partitions => INT32
replication_factor => INT16
configs => name value read_only config_source is_sensitive _tagged_fields
name => COMPACT_STRING
value => COMPACT_NULLABLE_STRING
read_only => BOOLEAN
config_source => INT8
is_sensitive => BOOLEAN
The following shows the binary representation
CreateTopics Request Binary representation example (simplified):
┌─────────────────────────────────────────────────────────────┐
│ Message Size: 150 bytes (Header + Body) │
├─────────────────────────────────────────────────────────────┤
│ Request Header: │
│ API Key: 19 (CreateTopics) │
│ API Version: 7 │
│ Correlation ID: 42 │
│ Client ID: "client" │
├─────────────────────────────────────────────────────────────┤
│ Request Body: │
│ Topics Array Length: 1 │
│ ┌───────────────────────────────────────────────────┐ │
│ │ Topic[0]: │ │
│ │ Name: "orders" (length: 6) │ │
│ │ Num Partitions: 3 │ │
│ │ Replication Factor: 2 │ │
│ │ Replica Assignments: null (length: -1) │ │
│ │ Configs Array Length: 2 │ │
│ │ ┌─────────────────────────────────────────┐ │ │
│ │ │ Config[0]: │ │ │
│ │ │ Name: "retention.ms" │ │ │
│ │ │ Value: "604800000" │ │ │
│ │ └─────────────────────────────────────────┘ │ │
│ │ ┌─────────────────────────────────────────┐ │ │
│ │ │ Config[1]: │ │ │
│ │ │ Name: "compression.type" │ │ │
│ │ │ Value: "lz4" │ │ │
│ │ └─────────────────────────────────────────┘ │ │
│ └───────────────────────────────────────────────────┘ │
│ Timeout: 30000 │
│ Validate Only: false │
└─────────────────────────────────────────────────────────────┘
CreateTopics Response Binary representation example (simplified):
┌─────────────────────┬──────────────────────────────────────┐
│ Throttle Time │ Rate limiting delay (ms) │
│ (int32) │ Usually 0 │
├─────────────────────┼──────────────────────────────────────┤
│ Topics │ Array of topic results │
│ (array) │ ┌─────────────────────────────────┐ │
│ │ │ Topic Name (string) │ │
│ │ │ Example: "orders" │ │
│ │ ├─────────────────────────────────┤ │
│ │ │ Topic ID (uuid) │ │
│ │ │ Unique identifier for topic │ │
│ │ ├─────────────────────────────────┤ │
│ │ │ Error Code (int16) │ │
│ │ │ 0 = Success │ │
│ │ │ 36 = TOPIC_ALREADY_EXISTS │ │
│ │ │ 37 = INVALID_PARTITIONS │ │
│ │ │ 38 = INVALID_REPLICATION │ │
│ │ │ 29 = INVALID_TOPIC_EXCEPTION │ │
│ │ ├─────────────────────────────────┤ │
│ │ │ Error Message (nullable string) │ │
│ │ │ Human-readable error details │ │
│ │ ├─────────────────────────────────┤ │
│ │ │ Num Partitions (int32) │ │
│ │ │ Actual partitions created │ │
│ │ ├─────────────────────────────────┤ │
│ │ │ Replication Factor (int16) │ │
│ │ │ Actual replication factor used │ │
│ │ ├─────────────────────────────────┤ │
│ │ │ Configs (array) │ │
│ │ │ Final topic configuration │ │
│ │ └─────────────────────────────────┘ │
└─────────────────────┴──────────────────────────────────────┘
What’s Next?#
Understanding the theory is the first step. In the next post, we will get our hands dirty and implement a basic Kafka server in Go to see these bytes in action.