Learn Zig Series (#21) - Networking and TCP Sockets
Learn Zig Series (#21) - Networking and TCP Sockets

What will I learn
- You will learn creating TCP servers with
std.net.Server; - binding to an address and accepting client connections;
- the client side: connecting to a remote host with
std.net.tcpConnectToHost; - reading and writing data over
std.net.Stream; - DNS resolution with
std.net.Address.resolveIp; - error handling patterns for network code;
- practical example: building a complete TCP echo server and client.
Requirements
- A working modern computer running macOS, Windows or Ubuntu;
- An installed Zig 0.14+ distribution (download from ziglang.org);
- The ambition to learn Zig programming.
Difficulty
- Intermediate
Curriculum (of the Learn Zig Series):
- Zig Programming Tutorial - ep001 - Intro
- Learn Zig Series (#2) - Hello Zig, Variables and Types
- Learn Zig Series (#3) - Functions and Control Flow
- Learn Zig Series (#4) - Error Handling (Zig's Best Feature)
- Learn Zig Series (#5) - Arrays, Slices, and Strings
- Learn Zig Series (#6) - Structs, Enums, and Tagged Unions
- Learn Zig Series (#7) - Memory Management and Allocators
- Learn Zig Series (#8) - Pointers and Memory Layout
- Learn Zig Series (#9) - Comptime (Zig's Superpower)
- Learn Zig Series (#10) - Project Structure, Modules, and File I/O
- Learn Zig Series (#11) - Mini Project: Building a Step Sequencer
- Learn Zig Series (#12) - Testing and Test-Driven Development
- Learn Zig Series (#13) - Interfaces via Type Erasure
- Learn Zig Series (#14) - Generics with Comptime Parameters
- Learn Zig Series (#15) - The Build System (build.zig)
- Learn Zig Series (#16) - Sentinel-Terminated Types and C Strings
- Learn Zig Series (#17) - Packed Structs and Bit Manipulation
- Learn Zig Series (#18) - Async Concepts and Event Loops
- Learn Zig Series (#18b) - Addendum: Async Returns in Zig 0.16
- Learn Zig Series (#19) - SIMD with @Vector
- Learn Zig Series (#20) - Working with JSON
- Learn Zig Series (#21) - Networking and TCP Sockets (this post)
Learn Zig Series (#21) - Networking and TCP Sockets
Welcome back! Last episode we tackled JSON parsing -- typed deserialization, dynamic value trees, streaming tokenization, the whole deal. I closed that one by saying "wait until you're parsing JSON that arrived over a socket you opened yourself." Well, here we are. Today we're doing networking.
TCP sockets are the backbone of pretty much everything on the internet. HTTP, WebSockets, database connections, SSH, SMTP, Redis, gRPC -- all of it runs on top of TCP. Even if you never write a raw TCP server yourself, understanding what happens below the HTTP layer makes you a fundamentally better developer. And in Zig, where you have full control over memory and no hidden runtime, networking code is about as close to the metal as you can get without writing assembly.
Zig's standard library ships with std.net which gives you everything: server sockets, client connections, address resolution, and a clean stream abstraction. No external dependencies, no libuv, no tokio -- just the POSIX socket API wrapped in Zig's type system with proper error handling. If you followed ep4 (error handling) and ep10 (file I/O), you already know the patterns. Networking is file I/O with an address attached ;-)
Here we go!
Solutions to Episode 20 Exercises
Exercise 1 -- Reading and summarizing a students.json file:
const std = @import("std");
const Student = struct {
name: []const u8,
grade: i32,
passed: bool,
notes: ?[]const u8 = null,
};
pub fn main() !void {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit();
const allocator = gpa.allocator();
const file = try std.fs.cwd().openFile("students.json", .{});
defer file.close();
const content = try file.readToEndAlloc(allocator, 1024 * 1024);
defer allocator.free(content);
const parsed = try std.json.parseFromSlice(
[]const Student,
allocator,
content,
.{ .ignore_unknown_fields = true },
);
defer parsed.deinit();
const students = parsed.value;
var total_grade: i64 = 0;
var passed_count: usize = 0;
for (students) |s| {
total_grade += s.grade;
if (s.passed) passed_count += 1;
}
std.debug.print("Total students: {d}\n", .{students.len});
std.debug.print("Passed: {d}\n", .{passed_count});
if (students.len > 0) {
const avg = @as(f64, @floatFromInt(total_grade)) /
@as(f64, @floatFromInt(students.len));
std.debug.print("Average grade: {d:.1}\n", .{avg});
}
std.debug.print("\nStudents with notes:\n", .{});
for (students) |s| {
if (s.notes) |notes| {
std.debug.print(" {s}: {s}\n", .{ s.name, notes });
}
}
}
Parse directly into []const Student -- the JSON top-level array maps straight to a Zig slice. The ?[]const u8 for notes handles both missing keys and explicit null. The average is computed with i64 accumulation to avoid overflow on large class sizes.
Exercise 2 -- A JSON pretty-printer using std.json.Scanner:
const std = @import("std");
pub fn main() !void {
const compact =
\\{"name":"Zig","version":0.14,"features":["comptime","simd"],"nested":{"a":1,"b":true}}
;
var scanner = std.json.Scanner.initCompleteInput(
std.heap.page_allocator,
compact,
);
defer scanner.deinit();
const stdout = std.io.getStdOut().writer();
var depth: usize = 0;
var need_indent = false;
var in_pair = false;
while (true) {
const token = try scanner.next();
switch (token) {
.object_begin => {
if (need_indent) try writeIndent(stdout, depth);
try stdout.writeAll("{\n");
depth += 1;
need_indent = true;
in_pair = false;
},
.object_end => {
depth -= 1;
try stdout.writeByte('\n');
try writeIndent(stdout, depth);
try stdout.writeByte('}');
need_indent = false;
in_pair = false;
},
.array_begin => {
if (need_indent) try writeIndent(stdout, depth);
try stdout.writeAll("[\n");
depth += 1;
need_indent = true;
in_pair = false;
},
.array_end => {
depth -= 1;
try stdout.writeByte('\n');
try writeIndent(stdout, depth);
try stdout.writeByte(']');
need_indent = false;
in_pair = false;
},
.string => |s| {
if (need_indent and !in_pair) try writeIndent(stdout, depth);
try stdout.print("\"{s}\"", .{s});
if (in_pair) {
in_pair = false;
} else {
try stdout.writeAll(": ");
in_pair = true;
}
need_indent = false;
},
.number => |n| {
if (need_indent and !in_pair) try writeIndent(stdout, depth);
try stdout.writeAll(n);
in_pair = false;
need_indent = false;
},
.true => {
try stdout.writeAll("true");
in_pair = false;
},
.false => {
try stdout.writeAll("false");
in_pair = false;
},
.null => {
try stdout.writeAll("null");
in_pair = false;
},
.end_of_document => break,
else => {},
}
}
try stdout.writeByte('\n');
}
fn writeIndent(writer: anytype, depth: usize) !void {
for (0..depth) |_| {
try writer.writeAll(" ");
}
}
The tricky part is tracking whether you're reading a key or a value inside an object. In JSON, object contents alternate: key, value, key, value. The in_pair flag handles this -- after seeing a string in object context, we print : and wait for the value. Array elements and non-string values just need indentation. This is a simplified version (doesn't handle commas between elements in the visual output), but it captures the core scanner-based approach.
Exercise 3 -- JSON diff comparing two Value trees:
const std = @import("std");
fn jsonDiff(
a: std.json.Value,
b: std.json.Value,
path: []const u8,
) void {
if (@intFromEnum(a) != @intFromEnum(b)) {
std.debug.print("{s}: type mismatch ({s} vs {s})\n", .{
path,
@tagName(a),
@tagName(b),
});
return;
}
switch (a) {
.object => |obj_a| {
const obj_b = b.object;
var it = obj_a.iterator();
while (it.next()) |entry| {
if (obj_b.get(entry.key_ptr.*)) |val_b| {
var buf: [256]u8 = undefined;
const child = std.fmt.bufPrint(
&buf,
"{s}.{s}",
.{ path, entry.key_ptr.* },
) catch path;
jsonDiff(entry.value_ptr.*, val_b, child);
} else {
std.debug.print("{s}.{s}: only in A\n", .{
path, entry.key_ptr.*,
});
}
}
var it_b = obj_b.iterator();
while (it_b.next()) |entry| {
if (obj_a.get(entry.key_ptr.*) == null) {
std.debug.print("{s}.{s}: only in B\n", .{
path, entry.key_ptr.*,
});
}
}
},
.array => |arr_a| {
const arr_b = b.array;
if (arr_a.items.len != arr_b.items.len) {
std.debug.print("{s}: array length {d} vs {d}\n", .{
path, arr_a.items.len, arr_b.items.len,
});
}
const min_len = @min(arr_a.items.len, arr_b.items.len);
for (0..min_len) |i| {
var buf: [256]u8 = undefined;
const child = std.fmt.bufPrint(
&buf,
"{s}[{d}]",
.{ path, i },
) catch path;
jsonDiff(arr_a.items[i], arr_b.items[i], child);
}
},
.string => |s_a| {
if (!std.mem.eql(u8, s_a, b.string)) {
std.debug.print("{s}: \"{s}\" vs \"{s}\"\n", .{
path, s_a, b.string,
});
}
},
.integer => |i_a| {
if (i_a != b.integer) {
std.debug.print("{s}: {d} vs {d}\n", .{
path, i_a, b.integer,
});
}
},
.float => |f_a| {
if (f_a != b.float) {
std.debug.print("{s}: {d} vs {d}\n", .{
path, f_a, b.float,
});
}
},
.bool => |b_a| {
if (b_a != b.bool) {
std.debug.print("{s}: {} vs {}\n", .{
path, b_a, b.bool,
});
}
},
else => {},
}
}
pub fn main() !void {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit();
const allocator = gpa.allocator();
const json_a =
\\{"name":"Alice","age":30,"tags":["dev","zig"]}
;
const json_b =
\\{"name":"Bob","age":30,"tags":["dev","rust"],"extra":true}
;
const a = try std.json.parseFromSlice(
std.json.Value, allocator, json_a, .{},
);
defer a.deinit();
const b = try std.json.parseFromSlice(
std.json.Value, allocator, json_b, .{},
);
defer b.deinit();
jsonDiff(a.value, b.value, "root");
}
The recursive approach handles nested objects and arrays naturally. @intFromEnum on the tagged union checks if both values are the same JSON type before comparing contents. The path string builds up like root.name or root.tags[1] so you know exactly where differences are. The std.fmt.bufPrint with a stack buffer avoids heap allocation for path building.
Alright, on to networking! ;-)
The std.net landscape
Before writing any code, let's map out what std.net gives us. The core types are:
std.net.Address-- a socket address (IP + port). Can be IPv4, IPv6, or Unix domain socket. This is Zig's wrapper aroundstruct sockaddr.std.net.Stream-- a connected TCP stream. Wraps a file descriptor. Has.read()and.write()methods. This is what you get after a succesful connection or accept.std.net.Server-- a listening TCP server. Binds to an address, listens, and accepts incoming connections. Each accepted connection gives you aStream.
The relationship is straightforward: a Server listens on an Address and produces Streams. A client connects to an Address and gets a Stream. Both sides use the same Stream type for reading and writing data. Symmetry.
If you've done POSIX socket programming in C, you'll recognize the pattern: socket() + bind() + listen() + accept() on the server side, socket() + connect() on the client side. Zig wraps all of that into cleaner types, but the underlying OS calls are the same.
Building a TCP server
Let's start with the server side. A basic TCP server in Zig:
const std = @import("std");
const net = std.net;
pub fn main() !void {
const address = net.Address.initIp4(.{ 127, 0, 0, 1 }, 8080);
var server = try address.listen(.{
.reuse_address = true,
});
defer server.deinit();
std.debug.print("Server listening on 127.0.0.1:8080\n", .{});
while (true) {
const connection = try server.accept();
defer connection.stream.close();
std.debug.print("Client connected from {}\n", .{connection.address});
const msg = "Hello from Zig!\n";
_ = try connection.stream.write(msg);
}
}
That's a working server in about 15 lines of actual logic. Let's break it down.
net.Address.initIp4 creates an IPv4 address from four octets and a port. The .{ 127, 0, 0, 1 } syntax is Zig's anonymous struct/array literal for the four bytes of 127.0.0.1. Port 8080 -- you know the drill.
.listen() is the big one. It does three things in one call: creates a socket, binds it to the address, and starts listening. The .reuse_address = true option sets SO_REUSEADDR so you can restart the server without waiting for the OS to release the port (without this, you'll get "address already in use" for up to a minute after stopping the server -- every developer has been bitten by this at least once).
.accept() blocks until a client connects, then returns a Connection struct containing a .stream (the Stream you'll read from and write to) and .address (the client's address). We close the stream with defer when we're done.
.write() sends bytes over the stream. It returns the number of bytes actually written -- in our case we're ignoring the return (with _ =) because the message is tiny and will always go out in one write. For production code you'd want to handle partial writes, which we'll cover in a moment.
You can test this right now. Build and run the server, then in another terminal:
echo "test" | nc 127.0.0.1 8080
You'll see "Hello from Zig!" come back. The server prints the client address, closes the connection, and waits for the next one.
Connecting as a client
Now the client side. Connecting to a TCP server:
const std = @import("std");
const net = std.net;
pub fn main() !void {
const stream = try net.tcpConnectToHost(
std.heap.page_allocator,
"127.0.0.1",
8080,
);
defer stream.close();
std.debug.print("Connected to server\n", .{});
// Read the server's response
var buf: [1024]u8 = undefined;
const n = try stream.read(&buf);
if (n > 0) {
std.debug.print("Received: {s}", .{buf[0..n]});
} else {
std.debug.print("Server closed connection\n", .{});
}
}
net.tcpConnectToHost takes an allocator, a hostname (or IP string), and a port. It does DNS resolution (if needed), creates a socket, and connects. The allocator is needed for DNS resolution buffers. You get back a Stream -- same type the server gets from .accept().
The .read() call fills our buffer and returns how many bytes were received. A return of 0 means the remote side closed the connection (EOF). This is the standard POSIX convention: read() returns 0 on EOF, never negative (errors are Zig errors, not return codes).
One thing to note: tcpConnectToHost does DNS resolution automatically. You can pass "localhost" or "example.com" and it'll resolve. We're using "127.0.0.1" here which skips DNS, but the same function handles both cases.
Reading and writing: getting it right
The basic .read() and .write() are thin wrappers around the OS calls. That means they have the same semantics: .read() might return fewer bytes than the buffer size (partial read), and .write() might accept fewer bytes than you offered (partial write). For small messages on localhost this rarely happens, but over a real network it's common.
For reading a complete message, you need a loop:
const std = @import("std");
const net = std.net;
fn readExact(stream: net.Stream, buf: []u8) !usize {
var total: usize = 0;
while (total < buf.len) {
const n = try stream.read(buf[total..]);
if (n == 0) return total; // EOF before buffer filled
total += n;
}
return total;
}
fn writeAll(stream: net.Stream, data: []const u8) !void {
var sent: usize = 0;
while (sent < data.len) {
const n = try stream.write(data[sent..]);
if (n == 0) return error.ConnectionReset;
sent += n;
}
}
Actually, Zig already provides stream.writeAll() which does exactly what our writeAll function does -- it loops until all bytes are sent or an error occurs. Use that instead of rolling your own. For reading, there's stream.readAll() too, which reads exactly buf.len bytes or returns an error. But for variable-length messages (where you don't know the size upfront), you'll typically use .read() in a loop with your own termination condition.
Here's a more complete client that sends data and reads the response:
const std = @import("std");
const net = std.net;
pub fn main() !void {
const stream = try net.tcpConnectToHost(
std.heap.page_allocator,
"127.0.0.1",
8080,
);
defer stream.close();
// Send a message
const message = "Hello, server!";
try stream.writeAll(message);
// Read response
var buf: [4096]u8 = undefined;
var total: usize = 0;
while (true) {
const n = stream.read(buf[total..]) catch |err| {
if (err == error.ConnectionResetByPeer) break;
return err;
};
if (n == 0) break;
total += n;
}
if (total > 0) {
std.debug.print("Response ({d} bytes): {s}\n", .{
total, buf[0..total],
});
}
}
The error.ConnectionResetByPeer catch is important. In real networking, the remote end might close the connection abruptly (kill the process, network failure, timeout). Without catching this, your program would crash. With it, you handle it as a clean disconnect.
Address resolution
std.net.Address.resolveIp converts a hostname string to an Address:
const std = @import("std");
const net = std.net;
pub fn main() !void {
// Parse an IP string into an Address
const addr1 = try net.Address.resolveIp("127.0.0.1", 8080);
std.debug.print("Resolved: {}\n", .{addr1});
// IPv6 works too
const addr2 = try net.Address.resolveIp("::1", 9090);
std.debug.print("IPv6: {}\n", .{addr2});
// Parse from a string that could be IPv4 or IPv6
const addr3 = try net.Address.resolveIp("0.0.0.0", 3000);
std.debug.print("Any: {}\n", .{addr3});
}
resolveIp handles both IPv4 and IPv6 string representations. It does NOT do DNS resolution -- it only parses IP address strings. For DNS resolution (hostname to IP), you need std.net.tcpConnectToHost which handles resolution internally, or you can use std.net.Address.getAddressList for lower-level control.
Here's a function that resolves a hostname to all its addresses:
const std = @import("std");
const net = std.net;
pub fn main() !void {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit();
const allocator = gpa.allocator();
const result = try net.Address.getAddressList(
allocator,
"localhost",
8080,
);
defer result.deinit();
std.debug.print("Addresses for 'localhost':\n", .{});
for (result.addrs) |addr| {
std.debug.print(" {}\n", .{addr});
}
}
getAddressList returns all addresses that a hostname resolves to -- useful when a host has both IPv4 and IPv6 records, or when a service is behind a load balancer with multiple IPs. Each address in the list is a complete net.Address you can use directly with .listen() or tcpConnectToAddress.
Error handling for network code
Network code fails in ways that file I/O doesn't. The remote machine can disappear. The network can partition. DNS can time out. Zig's error handling (from ep4) maps naturally to these failure modes:
const std = @import("std");
const net = std.net;
fn connectWithRetry(
allocator: std.mem.Allocator,
host: []const u8,
port: u16,
max_retries: u32,
) !net.Stream {
var attempt: u32 = 0;
while (true) {
const stream = net.tcpConnectToHost(allocator, host, port) catch |err| {
attempt += 1;
if (attempt >= max_retries) {
std.debug.print(
"Failed to connect after {d} attempts: {}\n",
.{ max_retries, err },
);
return err;
}
std.debug.print(
"Connection attempt {d} failed ({}), retrying...\n",
.{ attempt, err },
);
std.time.sleep(1_000_000_000 * attempt); // backoff: 1s, 2s, 3s...
continue;
};
return stream;
}
}
pub fn main() !void {
const stream = try connectWithRetry(
std.heap.page_allocator,
"127.0.0.1",
8080,
3,
);
defer stream.close();
std.debug.print("Connected succesfully!\n", .{});
var buf: [1024]u8 = undefined;
const n = try stream.read(&buf);
if (n > 0) {
std.debug.print("Received: {s}", .{buf[0..n]});
}
}
The retry loop uses std.time.sleep for a linear backoff (1 second, 2 seconds, 3 seconds). In production you'd probably want exponential backoff with jitter, but the pattern is the same: catch the connection error, decide whether to retry, and eventually give up.
Common network errors you'll see:
error.ConnectionRefused-- nothing is listening on that porterror.ConnectionResetByPeer-- remote closed the connection abruptlyerror.BrokenPipe-- tried to write to a closed connectionerror.NetworkUnreachable-- can't route to the destinationerror.AddressInUse-- another process is already bound to that port
Each of these is a first-class Zig error that works with try, catch, and error unions. No errno lookup tables, no WSAGetLastError -- just typed errors you can switch on.
Building a TCP echo server
Let's put it all together. An echo server is the "hello world" of networking: it reads whatever the client sends and echoes it back. Simple concept, but it exercises every fundamental: binding, accepting, reading, writing, and connection lifecycle.
const std = @import("std");
const net = std.net;
fn handleClient(connection: net.Server.Connection) void {
defer connection.stream.close();
std.debug.print("Client connected: {}\n", .{connection.address});
var buf: [4096]u8 = undefined;
while (true) {
const n = connection.stream.read(&buf) catch |err| {
std.debug.print("Read error: {}\n", .{err});
return;
};
if (n == 0) {
std.debug.print("Client disconnected: {}\n", .{
connection.address,
});
return;
}
std.debug.print("Echoing {d} bytes\n", .{n});
connection.stream.writeAll(buf[0..n]) catch |err| {
std.debug.print("Write error: {}\n", .{err});
return;
};
}
}
pub fn main() !void {
const address = net.Address.initIp4(.{ 0, 0, 0, 0 }, 7000);
var server = try address.listen(.{
.reuse_address = true,
});
defer server.deinit();
std.debug.print("Echo server listening on port 7000\n", .{});
while (true) {
const connection = server.accept() catch |err| {
std.debug.print("Accept error: {}\n", .{err});
continue;
};
handleClient(connection);
}
}
We bind to 0.0.0.0 port 7000 (all interfaces -- any client can connect, not just localhost). The accept loop runs forever, accepting one client at a time. For each client, handleClient reads and echoes until the client disconnects or an error occurs.
This is a sequential server -- it handles one client at a time. While one client is connected, others have to wait. For a tutorial, that's fine. For production, you'd want concurrent handling (threads, or eventually Zig's async when it lands). We'll explore concurrency patterns in a future episode.
The matching echo client
Here's the client that talks to our echo server:
const std = @import("std");
const net = std.net;
pub fn main() !void {
const stream = try net.tcpConnectToHost(
std.heap.page_allocator,
"127.0.0.1",
7000,
);
defer stream.close();
std.debug.print("Connected to echo server\n", .{});
const messages = [_][]const u8{
"Hello, echo server!",
"Zig is awesome",
"Third message",
};
var buf: [4096]u8 = undefined;
for (messages) |msg| {
try stream.writeAll(msg);
const n = try stream.read(&buf);
if (n == 0) {
std.debug.print("Server closed connection\n", .{});
return;
}
std.debug.print("Sent: \"{s}\" -> Got: \"{s}\"\n", .{
msg, buf[0..n],
});
}
std.debug.print("Done!\n", .{});
}
Run the server in one terminal, the client in another. You'll see each message echoed back. The server prints the connection info and byte counts. When the client is done (exits the for loop, defer closes the stream), the server sees n == 0 and prints "Client disconnected."
A more practical example: line-based protocol
Most real protocols are line-based (HTTP headers, SMTP, Redis, IRC). Let's build a server that reads lines and responds:
const std = @import("std");
const net = std.net;
fn handleConnection(connection: net.Server.Connection) void {
defer connection.stream.close();
const client_addr = connection.address;
std.debug.print("[{any}] connected\n", .{client_addr});
// Send welcome message
connection.stream.writeAll("Welcome! Commands: PING, TIME, QUIT\n") catch return;
var buf: [4096]u8 = undefined;
var line_buf: [1024]u8 = undefined;
var line_len: usize = 0;
while (true) {
const n = connection.stream.read(&buf) catch return;
if (n == 0) return;
// Process bytes, looking for newlines
for (buf[0..n]) |byte| {
if (byte == '\n' or byte == '\r') {
if (line_len == 0) continue; // skip empty lines
const line = line_buf[0..line_len];
processCommand(connection.stream, line) catch return;
line_len = 0;
} else {
if (line_len < line_buf.len) {
line_buf[line_len] = byte;
line_len += 1;
}
}
}
}
}
fn processCommand(stream: net.Stream, line: []const u8) !void {
if (std.mem.eql(u8, line, "PING")) {
try stream.writeAll("PONG\n");
} else if (std.mem.eql(u8, line, "TIME")) {
const ts = std.time.timestamp();
var buf: [64]u8 = undefined;
const msg = std.fmt.bufPrint(&buf, "TIME: {d}\n", .{ts}) catch return;
try stream.writeAll(msg);
} else if (std.mem.eql(u8, line, "QUIT")) {
try stream.writeAll("Bye!\n");
return error.BrokenPipe; // force disconnect
} else {
try stream.writeAll("ERROR: unknown command\n");
}
}
pub fn main() !void {
const address = net.Address.initIp4(.{ 127, 0, 0, 1 }, 7001);
var server = try address.listen(.{
.reuse_address = true,
});
defer server.deinit();
std.debug.print("Command server on port 7001\n", .{});
while (true) {
const conn = server.accept() catch continue;
handleConnection(conn);
}
}
You can test this interactively:
nc 127.0.0.1 7001
Then type PING, TIME, or QUIT. The manual line parsing (scanning for \n) is the same pattern you'd use for any text protocol. In production you might wrap this in a buffered reader, but the low-level approach here shows exactly what's happening -- no abstraction hiding the byte-by-byte reality of TCP.
Having said that, TCP is a stream protocol -- there are no message boundaries. When you send "HELLO\n" the receiver might get "HEL" in one read and "LO\n" in the next. That's why we accumulate bytes into line_buf until we see a newline. This is fundamental networking and it's a mistake I see constantly in beginner code: assuming that one write() on the sender produces one read() on the receiver. It doesn't. TCP gives you a byte stream, nothing more ;-)
Socket options
The .listen() options struct has several useful fields beyond .reuse_address:
const std = @import("std");
const net = std.net;
pub fn main() !void {
const address = net.Address.initIp4(.{ 0, 0, 0, 0 }, 9000);
var server = try address.listen(.{
.reuse_address = true,
.kernel_backlog = 128, // max pending connections
});
defer server.deinit();
std.debug.print("Listening on port 9000 (backlog: 128)\n", .{});
// The stream file descriptor gives you access to
// low-level socket options via std.posix
const fd = server.stream.handle;
std.debug.print("Server socket fd: {d}\n", .{fd});
const conn = try server.accept();
defer conn.stream.close();
// Client stream also exposes its fd
std.debug.print("Client fd: {d}, addr: {}\n", .{
conn.stream.handle,
conn.address,
});
_ = try conn.stream.write("Socket options demo!\n");
}
The kernel_backlog controls how many connections can queue up waiting for accept(). The default varies by OS (usually 128 or higher). Setting it explicitly is good practice for servers that expect bursts of connections.
Both the server and client streams expose their underlying file descriptor through .handle. This lets you call lower-level functions from std.posix when you need socket options that the high-level API doesn't expose (TCP keepalive intervals, buffer sizes, etc.).
IPv6 support
Everything we've done works with IPv6 too -- just use initIp6 instead of initIp4:
const std = @import("std");
const net = std.net;
pub fn main() !void {
// Listen on IPv6 loopback
const address = net.Address.initIp6(
.{ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 }, // ::1
8080,
0, // flow info
0, // scope id
);
var server = try address.listen(.{
.reuse_address = true,
});
defer server.deinit();
std.debug.print("Listening on [::1]:8080\n", .{});
const conn = try server.accept();
defer conn.stream.close();
_ = try conn.stream.write("Hello from IPv6!\n");
}
Or use resolveIp which auto-detects the address family:
const std = @import("std");
const net = std.net;
pub fn main() !void {
// Auto-detect: this could be IPv4 or IPv6
const addr_v4 = try net.Address.resolveIp("127.0.0.1", 8080);
const addr_v6 = try net.Address.resolveIp("::1", 8080);
std.debug.print("v4: {}\n", .{addr_v4});
std.debug.print("v6: {}\n", .{addr_v6});
}
The Stream type doesn't care whether the underlying connection is IPv4 or IPv6. Once you have a connected stream, reading and writing works identically. The address family only matters at connection/binding time.
When to use what
A quick reference for which std.net function to reach for:
- Starting a server:
Address.initIp4(...)orAddress.resolveIp(...)then.listen() - Accepting clients:
server.accept()in a loop - Connecting to a known IP:
net.tcpConnectToAddress(address) - Connecting to a hostname:
net.tcpConnectToHost(allocator, host, port)-- handles DNS - Sending data:
stream.writeAll(data)-- loops until everything is sent - Receiving data:
stream.read(&buf)-- returns available bytes (may be partial) - Reading exact amount:
stream.readAll(&buf)-- fills the whole buffer or errors - Cleaning up:
stream.close(),server.deinit()-- always in defer
The Stream type is essentially a file descriptor with network semantics. If you've used std.fs.File from ep10, the read/write interface is nearly identical. Zig treats everything as files under the hood -- the Unix philosophy at work.
Exercises
Modify the echo server to become a "counting echo server": for each client, maintain a counter of how many messages have been echoed. Prefix each response with the message number, like
[1] Hello,[2] Zig is great, etc. The counter resets for each new client connection. This requires tracking state per-connection.Build a simple "key-value store" over TCP. The server accepts commands:
SET key value,GET key, andDEL key. Store key-value pairs in astd.StringHashMap. Respond withOKfor SET/DEL and the value (orNOT_FOUND) for GET. Use the line-based parsing pattern from the protocol example above. Hint: you'll need to split each line on spaces --std.mem.splitScalaris your friend.Write a TCP "port scanner" that takes a host and a range of ports (e.g. 1-1024) and reports which ports are open. For each port, attempt a connection with
tcpConnectToHost. If it succeeds, the port is open. If it fails withConnectionRefused, the port is closed. Print the results sorted by port number. Be mindful that scanning takes time -- you'll feel how sequential connections add up when checking hundres of ports.
Dusssssss, wat hebben we nou geleerd?
std.net.Serverbinds to an address and listens for connections. The.listen()call creates the socket, binds, and starts listening in one shot.std.net.Streamis the connected socket -- same type for both server-accepted and client-initiated connections. It has.read(),.write(),.writeAll(),.readAll(), and.close().std.net.tcpConnectToHostconnects to a hostname (with DNS resolution) and returns aStream. UsetcpConnectToAddressif you already have a resolvedAddress.std.net.Addresswraps socket addresses.initIp4for literals,resolveIpfor parsing strings,getAddressListfor full DNS resolution.- TCP is a byte stream -- there are no message boundaries. One write can produce multiple reads, and multiple writes can merge into one read. If your protocol has messages, you need framing (length prefix, delimiter like
\n, or both). - Error handling maps cleanly to Zig errors:
ConnectionRefused,ConnectionResetByPeer,BrokenPipe,AddressInUse-- all caught withtry/catch. - The server we built is sequential (one client at a time). Concurrent handling with threads is a topic for a future episode where we'll look at how data structures and networking interact.
Bedankt en tot de volgende keer!
Congratulations @scipio! You have completed the following achievement on the Hive blockchain And have been rewarded with New badge(s)
Your next payout target is 5000 HP.
The unit is Hive Power equivalent because post and comment rewards can be split into HP and HBD
You can view your badges on your board and compare yourself to others in the Ranking
If you no longer want to receive notifications, reply to this comment with the word
STOP