Skip to content

Writing a TCP Server

A TCP server is a program that waits for clients to connect.

A TCP server is a program that waits for clients to connect.

Once a client connects, the server can read bytes from the client and write bytes back. This is the foundation of many network programs: web servers, databases, proxies, chat servers, RPC services, and development tools.

TCP gives your program a reliable byte stream. Bytes arrive in order, or the connection fails. TCP does not give you messages. Your protocol must decide where messages begin and end.

The Shape of a TCP Server

Most TCP servers follow this structure:

create address
listen on address
accept connection
handle connection
close connection
repeat

In Zig, a small server looks like this:

const std = @import("std");

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 {
    _ = try stream.write("hello from zig\n");
}

This server listens on 127.0.0.1:9000. When a client connects, it sends one line, then closes the connection.

Listening Address

This line creates the address:

const address = try std.net.Address.parseIp("127.0.0.1", 9000);

127.0.0.1 means localhost. Only programs on the same machine can connect.

Port 9000 is the TCP port.

To listen on all IPv4 network interfaces, you may use:

const address = try std.net.Address.parseIp("0.0.0.0", 9000);

Be careful with this. It may expose your server to other machines on the network.

For learning, start with 127.0.0.1.

Listening

This line starts the listening socket:

var server = try address.listen(.{
    .reuse_address = true,
});

The server now asks the operating system to accept TCP connections at that address.

The option:

.reuse_address = true

helps when restarting the server during development. It allows the address to be reused in common cases where the previous socket is still temporarily remembered by the OS.

Accepting Connections

This waits for a client:

const connection = try server.accept();

accept blocks until a client connects.

The returned connection contains a stream:

connection.stream

A stream is something you can read from and write to.

After you finish with the client, close the stream:

defer connection.stream.close();

This releases the operating system resource.

Testing with Netcat

Run the server, then connect with:

nc 127.0.0.1 9000

You should see:

hello from zig

Then the connection closes.

nc is useful because it lets you manually connect to TCP servers and type bytes.

Handling One Client at a Time

The first server handles one client at a time.

while (true) {
    const connection = try server.accept();
    defer connection.stream.close();

    try handleConnection(connection.stream);
}

There is an important bug here for a long-running loop: defer runs when the current scope exits, not at the end of each loop iteration.

Since the scope is main, each accepted connection would remain open until the program exits.

Fix this by using a block:

while (true) {
    const connection = try server.accept();

    {
        defer connection.stream.close();
        try handleConnection(connection.stream);
    }
}

Now the stream is closed at the end of the block for each connection.

This is a subtle but important Zig lifetime detail.

Echo Server

An echo server reads bytes from the client and writes the same bytes back.

const std = @import("std");

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;

    while (true) {
        const n = try stream.read(&buffer);

        if (n == 0) {
            break;
        }

        try stream.writeAll(buffer[0..n]);
    }
}

The read loop is the core:

const n = try stream.read(&buffer);

if (n == 0) {
    break;
}

try stream.writeAll(buffer[0..n]);

If read returns 0, the client closed the connection.

Otherwise, the server writes back the bytes it received.

write vs writeAll

A low-level write may write only part of the bytes.

This is why the echo server uses:

try stream.writeAll(buffer[0..n]);

writeAll keeps writing until all bytes are written or an error occurs.

For beginner server code, use writeAll unless you specifically need partial-write control.

TCP Is a Byte Stream

TCP has no message boundaries.

If a client writes:

hello

the server may receive:

hello

But if the client writes many pieces:

hello
world

the server may receive:

helloworld

or:

hel
low
orld

This is normal.

A TCP server should not assume one read equals one client message.

If your protocol needs messages, define framing. For example, newline-delimited messages:

PING\n
ECHO hello\n
QUIT\n

or length-prefixed messages:

4-byte length
payload bytes

Line-Based Server

Here is a server that reads one line at a time.

const std = @import("std");

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;

        if (std.mem.eql(u8, line, "quit")) {
            try writer.writeAll("BYE\n");
            break;
        }

        try writer.print("you said: {s}\n", .{line});
    }
}

This server waits for newline-terminated input.

Try it:

nc 127.0.0.1 9000

Then type:

hello

The server replies:

you said: hello

Type:

quit

The server replies:

BYE

and closes the connection.

Handling \r\n

Many text protocols use CRLF line endings:

\r\n

If you read until \n, the line may still end with \r.

Clean it with:

const clean_line = std.mem.trimRight(u8, line, "\r");

Then compare clean_line instead of line.

if (std.mem.eql(u8, clean_line, "quit")) {
    try writer.writeAll("BYE\n");
    break;
}

This makes your server friendlier to clients that send CRLF.

Adding Simple Commands

Now define a tiny protocol:

PING
ECHO <text>
QUIT

Responses:

PONG
OK <text>
BYE
ERR unknown command
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;
}

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;
        },
    }
}

Then use it inside handleConnection:

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;
        }
    }
}

This is a real small protocol server.

One Client Blocks the Others

Our server still handles one client at a time.

If one client connects and does nothing, the server waits inside handleConnection. During that time, other clients cannot be handled.

This is acceptable for a learning server. It is not acceptable for most real servers.

There are several ways to handle multiple clients:

use one thread per connection

use a thread pool

use non-blocking sockets and an event loop

use async I/O where supported

use OS-specific polling APIs such as epoll, kqueue, or IOCP

The simplest next step is one thread per connection.

Thread Per Connection

Conceptually:

main thread:
    accept client
    start worker thread
    go back to accept

worker thread:
    handle client
    close connection

A sketch:

while (true) {
    const connection = try server.accept();

    const thread = try std.Thread.spawn(.{}, handleConnectionThread, .{
        connection.stream,
    });

    thread.detach();
}

The worker function owns the stream:

fn handleConnectionThread(stream: std.net.Stream) void {
    defer stream.close();

    handleConnection(stream) catch |err| {
        std.debug.print("connection error: {}\n", .{err});
    };
}

This lets multiple clients connect at once.

Thread-per-connection is simple, but it can use too many resources if there are many clients. Production servers often use thread pools or event loops.

Error Handling in Servers

A server should not usually crash because one client misbehaves.

This is fragile:

try handleConnection(connection.stream);

If one connection returns an error, the whole server may exit.

Better:

handleConnection(connection.stream) catch |err| {
    std.debug.print("connection error: {}\n", .{err});
};

Now the server logs the error and continues accepting other clients.

For the listener itself, errors may be more serious. You still need to decide whether to retry, log, or exit.

Timeouts

A real server should avoid waiting forever.

A client might connect and never send data. If the server dedicates a thread to that client, the thread is wasted.

Timeout handling is OS-specific and depends on the networking model. The beginner-level rule is simple:

Do not forget that reads can block forever.

For production systems, design timeouts for:

connection setup

read inactivity

write blockage

full request duration

idle keep-alive connections

Timeouts are part of server correctness, not only performance.

Limits

Servers must have limits.

For the line-based server, the buffer is:

var buffer: [1024]u8 = undefined;

That means a line cannot exceed 1024 bytes.

This is good. Unlimited input is dangerous.

Real servers should define limits for:

maximum line length

maximum request body size

maximum number of connections

maximum time per request

maximum memory per client

maximum queued work

Without limits, a server is easy to exhaust.

Binding to Public Interfaces

This address listens only on the local machine:

127.0.0.1

This address listens on all IPv4 interfaces:

0.0.0.0

Use 127.0.0.1 for local development.

Use 0.0.0.0 only when you intentionally want other machines to connect.

When exposing a server, think about firewalls, authentication, TLS, input validation, and logging.

Mental Model

A TCP server owns a listening socket.

The listening socket accepts connections.

Each connection is a byte stream.

Your code must read bytes, apply protocol framing, handle commands, write responses, and close resources.

Start with a single-client server. Then add framing. Then add command handling. Then add concurrency. Then add limits and timeouts.