A network protocol is a rulebook for how programs talk over a network.
When two programs communicate, they cannot just send random bytes and hope the other side understands. They need an agreed format.
A protocol defines things like:
What messages exist?
What does each message mean?
How are messages encoded as bytes?
Who sends first?
How does the other side respond?
How are errors represented?
When does the connection close?
That agreement is the protocol.
A Simple Example
Imagine a tiny protocol for asking a server to echo text.
The client sends:
ECHO helloThe server replies:
OK helloIf the command is unknown, the server replies:
ERR unknown commandThis is a text protocol. The messages are readable.
A binary protocol might encode the same idea with bytes:
01 00 00 00 05 68 65 6C 6C 6FThose bytes could mean:
command = 1
length = 5
payload = "hello"Both styles are valid. The important part is that both programs agree.
Layers
Network communication is usually organized in layers.
A simplified view:
| Layer | Example | What It Handles |
|---|---|---|
| Application | HTTP, DNS, Redis protocol, PostgreSQL protocol | Meaning of messages |
| Transport | TCP, UDP | Moving bytes or packets between programs |
| Internet | IP | Moving packets between machines |
| Link | Ethernet, Wi-Fi | Moving frames on a local network |
As an application programmer, you usually work at the application layer.
For example, when writing an HTTP server, you usually do not build Ethernet frames. You read and write bytes over a TCP connection. Then you parse those bytes according to HTTP rules.
TCP and UDP
Most beginner network programs start with TCP.
TCP gives you a reliable byte stream. If bytes arrive, they arrive in order. TCP handles retransmission, ordering, and flow control.
But TCP does not preserve message boundaries.
If the client sends this:
hello
worldThe server might receive it as:
helloworldor:
hel
lowor
ldTCP gives you bytes, not messages.
Your protocol must define where one message ends and the next begins.
UDP is different. UDP sends datagrams. Each send corresponds to one packet-like message, but delivery is not guaranteed. Packets may be lost, duplicated, or arrive out of order.
Use TCP first. Learn UDP when you need packet-style communication, low latency tradeoffs, or custom reliability.
Message Framing
Message framing means deciding how to separate messages in a byte stream.
For TCP protocols, this is essential.
Common framing methods:
| Method | Example | Notes |
|---|---|---|
| newline-delimited | PING\n | Simple for text protocols |
| fixed-size messages | exactly 32 bytes | Easy but inflexible |
| length-prefixed | length + payload | Common for binary protocols |
| delimiter-based | message ends with \0 | Simple, but escaping may be needed |
| structured format | HTTP headers plus body length | More complex but flexible |
A beginner-friendly protocol can use newline-delimited messages.
Example:
PING\n
ECHO hello\n
QUIT\nThe server reads until newline, processes the command, then reads the next line.
A Tiny Text Protocol
Let’s define a small protocol.
Client commands:
PING
ECHO <text>
QUITServer replies:
PONG
OK <text>
BYE
ERR <message>Rules:
PING returns PONG.
ECHO hello returns OK hello.
QUIT returns BYE and closes the connection.
Unknown commands return ERR unknown command.
Each message ends with a newline byte, \n.
This is enough to build a real toy protocol.
Parsing Commands
A parser turns bytes into meaning.
For the text protocol, the input is one line:
const line = "ECHO hello";We can parse it by checking prefixes.
const std = @import("std");
const Command = union(enum) {
ping,
echo: []const u8,
quit,
unknown,
};
fn parseCommand(line: []const u8) Command {
if (std.mem.eql(u8, line, "PING")) {
return .ping;
}
if (std.mem.eql(u8, line, "QUIT")) {
return .quit;
}
if (std.mem.startsWith(u8, line, "ECHO ")) {
return .{ .echo = line[5..] };
}
return .unknown;
}This parser does not allocate memory. It returns slices into the original line.
That is common in Zig. Parse by borrowing input when possible.
Handling Commands
Now write the command behavior.
fn handleCommand(writer: anytype, command: Command) !bool {
switch (command) {
.ping => {
try writer.writeAll("PONG\n");
return true;
},
.echo => |text| {
try writer.print("OK {s}\n", .{text});
return true;
},
.quit => {
try writer.writeAll("BYE\n");
return false;
},
.unknown => {
try writer.writeAll("ERR unknown command\n");
return true;
},
}
}The return value tells the server whether to keep the connection open.
true means continue.
false means close.
A Simple TCP Server
Here is a small TCP server using Zig’s standard library networking APIs.
const std = @import("std");
const Command = union(enum) {
ping,
echo: []const u8,
quit,
unknown,
};
pub fn main() !void {
const address = try std.net.Address.parseIp("127.0.0.1", 9000);
var server = try address.listen(.{
.reuse_address = true,
});
defer server.deinit();
std.debug.print("listening on 127.0.0.1:9000\n", .{});
while (true) {
const connection = try server.accept();
defer connection.stream.close();
try handleConnection(connection.stream);
}
}
fn handleConnection(stream: std.net.Stream) !void {
var buffer: [1024]u8 = undefined;
const reader = stream.reader();
const writer = stream.writer();
try writer.writeAll("READY\n");
while (true) {
const line_or_null = try reader.readUntilDelimiterOrEof(&buffer, '\n');
const line = line_or_null orelse break;
const clean_line = std.mem.trimRight(u8, line, "\r");
const command = parseCommand(clean_line);
const keep_going = try handleCommand(writer, command);
if (!keep_going) {
break;
}
}
}
fn parseCommand(line: []const u8) Command {
if (std.mem.eql(u8, line, "PING")) {
return .ping;
}
if (std.mem.eql(u8, line, "QUIT")) {
return .quit;
}
if (std.mem.startsWith(u8, line, "ECHO ")) {
return .{ .echo = line[5..] };
}
return .unknown;
}
fn handleCommand(writer: anytype, command: Command) !bool {
switch (command) {
.ping => {
try writer.writeAll("PONG\n");
return true;
},
.echo => |text| {
try writer.print("OK {s}\n", .{text});
return true;
},
.quit => {
try writer.writeAll("BYE\n");
return false;
},
.unknown => {
try writer.writeAll("ERR unknown command\n");
return true;
},
}
}This server handles one connection at a time. That is fine for learning. Later, you can add threads, event loops, or async designs.
Testing with Netcat
You can test the server with nc:
nc 127.0.0.1 9000Then type:
PINGExpected reply:
READY
PONGTry:
ECHO hello zigExpected reply:
OK hello zigTry:
QUITExpected reply:
BYEWhy Newlines Matter
The server reads with:
reader.readUntilDelimiterOrEof(&buffer, '\n')This means one message ends at newline.
Without framing, the server would not know when to process a command.
For example, if the client sends:
ECHO hellobut never sends \n, the server keeps waiting. The message is incomplete according to the protocol.
Protocols need these rules. Otherwise both sides may wait forever.
Buffer Limits
Our example uses:
var buffer: [1024]u8 = undefined;That means one command line can be at most 1024 bytes in this simple server.
This is good. Protocols should have limits.
Without limits, a client can send an extremely large line and force your server to allocate memory or wait forever.
Real protocols define maximum sizes:
maximum line length
maximum header size
maximum body size
maximum number of fields
maximum message depth
Limits are part of safe protocol design.
Binary Protocols
A binary protocol is usually more compact and faster to parse, but less convenient to debug manually.
A simple length-prefixed message could look like this:
bytes 0..4 length, u32 little-endian
bytes 4..N payloadParser shape:
fn parseLengthPrefix(bytes: []const u8) ![]const u8 {
if (bytes.len < 4) {
return error.Truncated;
}
const len = std.mem.readInt(u32, bytes[0..4], .little);
const start: usize = 4;
const end = start + @as(usize, len);
if (end > bytes.len) {
return error.Truncated;
}
return bytes[start..end];
}This is only the parsing idea. A real TCP reader needs to keep reading until enough bytes are available.
Partial Reads
Network reads are often partial.
If a protocol says the next message has 100 bytes, one read may return only 20 bytes. Your code must continue reading until it has all 100 bytes.
This is one of the most important network programming rules.
Never assume one read equals one message.
For TCP:
send does not equal receive
write does not equal read
packet does not equal protocol messageYour framing code must handle this.
Protocol State
Some protocols have state.
Example:
client connects
server sends READY
client sends AUTH username password
server sends OK
client sends commands
client sends QUIT
server closesBefore authentication, only AUTH is allowed.
After authentication, more commands are allowed.
This means the server needs a state variable.
const State = enum {
waiting_for_auth,
ready,
closing,
};Then command handling checks the current state.
var state: State = .waiting_for_auth;A protocol is not only message formats. It is also the allowed sequence of messages.
Errors Are Part of the Protocol
A good protocol defines error replies.
Bad:
something failedBetter:
ERR unknown command
ERR invalid argument
ERR not authenticated
ERR message too largeEven better for machine clients:
ERR 1001 unknown_command
ERR 1002 invalid_argumentHuman-readable errors are useful. Stable machine-readable error codes are better for clients.
Versioning
Protocols evolve.
Version 1 may support:
PING
ECHO
QUITVersion 2 may add:
TIME
AUTHVersioning can be explicit:
HELLO 1or part of a header:
PROTO/1Without versioning, clients and servers may disagree silently.
For small private tools, this may not matter. For public protocols, it matters a lot.
Text Protocols vs Binary Protocols
| Choice | Advantages | Disadvantages |
|---|---|---|
| Text | Easy to debug, easy to type manually, readable logs | Larger, parsing can be slower, escaping rules needed |
| Binary | Compact, fast, exact types | Harder to inspect, requires tools, stricter parsing |
Start with text when learning. Use binary when you need compactness, speed, or exact layout.
Security Basics
Network protocols receive untrusted input.
Your server must assume clients may send invalid, huge, slow, or malicious data.
Basic rules:
Set size limits.
Validate all lengths.
Handle malformed input.
Avoid unchecked integer overflow.
Do not trust client-provided paths.
Add timeouts for slow clients.
Close connections that violate the protocol.
Keep error handling explicit.
A network parser should never crash just because the client sent bad bytes.
Mental Model
A network protocol is a contract over bytes.
TCP and UDP move data. Your protocol gives that data meaning.
In Zig, you usually model this with slices, parsers, enums, tagged unions, explicit errors, and careful buffer management. Start with a simple text protocol. Once the framing and state rules are clear, the same ideas transfer to larger binary protocols.