Skip to content

Network Protocols

A network protocol is a rulebook for how programs talk over a network.

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 hello

The server replies:

OK hello

If the command is unknown, the server replies:

ERR unknown command

This 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 6F

Those 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:

LayerExampleWhat It Handles
ApplicationHTTP, DNS, Redis protocol, PostgreSQL protocolMeaning of messages
TransportTCP, UDPMoving bytes or packets between programs
InternetIPMoving packets between machines
LinkEthernet, Wi-FiMoving 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
world

The server might receive it as:

helloworld

or:

hel
lowor
ld

TCP 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:

MethodExampleNotes
newline-delimitedPING\nSimple for text protocols
fixed-size messagesexactly 32 bytesEasy but inflexible
length-prefixedlength + payloadCommon for binary protocols
delimiter-basedmessage ends with \0Simple, but escaping may be needed
structured formatHTTP headers plus body lengthMore complex but flexible

A beginner-friendly protocol can use newline-delimited messages.

Example:

PING\n
ECHO hello\n
QUIT\n

The 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>
QUIT

Server 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 9000

Then type:

PING

Expected reply:

READY
PONG

Try:

ECHO hello zig

Expected reply:

OK hello zig

Try:

QUIT

Expected reply:

BYE

Why 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 hello

but 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      payload

Parser 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 message

Your 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 closes

Before 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 failed

Better:

ERR unknown command
ERR invalid argument
ERR not authenticated
ERR message too large

Even better for machine clients:

ERR 1001 unknown_command
ERR 1002 invalid_argument

Human-readable errors are useful. Stable machine-readable error codes are better for clients.

Versioning

Protocols evolve.

Version 1 may support:

PING
ECHO
QUIT

Version 2 may add:

TIME
AUTH

Versioning can be explicit:

HELLO 1

or part of a header:

PROTO/1

Without 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

ChoiceAdvantagesDisadvantages
TextEasy to debug, easy to type manually, readable logsLarger, parsing can be slower, escaping rules needed
BinaryCompact, fast, exact typesHarder 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.