ENTRY // 2025.05.31

WIP: TCP and UDP Networking in Zig 0.16

My experience with the new std.Io.net API

Disclaimer

This post is still very much WIP(Work in progress). I am still trying to wrap my head around std.Io and std.Io.net

Until then this post will be treated as a living document.

Zig 0.16 ships a new std.Io abstraction that replaces the "old" direct-posix networking pattern.
Most resources online still show the old way. I wanted to cover what works (on my machine) in 0.16 based on my understanding of lib/std/Io/net.zig.

TLDR here is the code if you just want to poke around: codeberg.org/MarioMottl/zignet

01 - THE NEW API

std.Io.net

Everything lives under std.Io.net.
The two entry points are:

GoalCallReturns
TCP serveraddr.listen(io, .{ .mode = .stream })net.Server
UDP socketaddr.bind(io, .{ .mode = .dgram })net.Socket
TCP clientaddr.connect(io, .{ .mode = .stream })net.Stream

For IPv4 net.IpAddress.parseIp4(host, port) parses the address.
For IPv6 net.IpAddress.parseIp6(host, port) parses the address.

All operations take an Io handle - obtained from init.io,
through the new main signature: pub fn main(init: std.process.Init).

For more information see: Juicy Main

INFO

The following examples will only focus on IPv4.

02 - TCP SERVER

TCP server

const std = @import("std");
const Io = std.Io;
const net = std.Io.net;

const debug_print = std.debug.print;

pub fn main(init: std.process.Init) !void {
    const io = init.io;

    const addr = try net.IpAddress.parseIp4("0.0.0.0", 2100);
    var server = try addr.listen(io, .{ .mode = .stream });
    defer server.deinit(io);
    debug_print("TCP listening on 0.0.0.0:2100\n", .{});

    while (true) {
        var stream = try server.accept(io);
        defer stream.close(io);
        debug_print("Client connected\n", .{});

        var read_buf: [4096]u8 = undefined;
        var write_buf: [4096]u8 = undefined;
        var reader = stream.reader(io, &read_buf);
        var writer = stream.writer(io, &write_buf);

        while (true) {
            // SEE: Caveats underneath
            reader.interface.fillMore() catch break;
            const chunk = reader.interface.buffered();
            if (chunk.len == 0) continue;
            try writer.interface.writeAll(chunk);
            try writer.interface.flush();
            reader.interface.tossBuffered();
        }

        debug_print("Client disconnected\n", .{});
    }
}

net.Server.accept blocks until a client connects and returns a net.Stream.
stream.reader and stream.writer wrap it in buffered I/O both require caller-provided backing buffers.

Caveats

pub fn fillMore(r: *Reader) is used because it does exactly one syscall and returns whatever arrived. For my echo-server example this is exactly what we want.
fn fill(r: *Reader, n: usize) blocks until exactly n bytes are in the buffer.
If the stream closes beforehand fill(...) will return an error.EndOfStream.

readSliceShort(&buf) loops until the buffer is full, returning a short count only if the stream ends early.
readSliceAll(&buf) does the same but returns error.EndOfStream instead of a short count if the stream ends early.

readAlloc(allocator, len) is a shortcut for readSliceAll - allocates len bytes via allocator and fills them from the stream.

Only fillMore(...) is not blocking AFAIK.

reader.interface.fillMore() catch break;
const chunk: []u8 = reader.interface.buffered(); // slice into read_buf
try writer.interface.writeAll(chunk);            // copy to write_buf
try writer.interface.flush();                    // drain write_buf to socket
reader.interface.tossBuffered();                 // mark bytes consumed
03 - TCP CLIENT

TCP client

const std = @import("std");
const Io = std.Io;
const net = std.Io.net;

const print = std.debug.print;

pub fn main(init: std.process.Init) !void {
    const io = init.io;

    const addr = try net.IpAddress.parseIp4("127.0.0.1", 2100);
    var stream = try addr.connect(io, .{ .mode = .stream });
    defer stream.close(io);
    print("Connected to 127.0.0.1:2100\n", .{});

    var read_buf: [4096]u8 = undefined;
    var write_buf: [4096]u8 = undefined;
    var reader = stream.reader(io, &read_buf);
    var writer = stream.writer(io, &write_buf);

    try writer.interface.writeAll("Hello from TCP client!\n");
    try writer.interface.flush();

    reader.interface.fillMore() catch {};
    const chunk = reader.interface.buffered();
    print("Echo: {s}", .{chunk});
}

addr.connect gives back the same net.Stream type as server.accept - the read/write API is identical on both ends.

04 - UDP SERVER

UDP server

UDP is simpler. No connections, no streams, no buffering layer.

const std = @import("std");
const Io = std.Io;
const net = std.Io.net;

const debug_print = std.debug.print;

pub fn main(init: std.process.Init) !void {
    const io = init.io;

    const addr = try net.IpAddress.parseIp4("0.0.0.0", 2101);
    var socket = try addr.bind(io, .{ .mode = .dgram });
    defer socket.close(io);
    debug_print("UDP listening on 0.0.0.0:2101\n", .{});

    var buf: [65536]u8 = undefined;

    while (true) {
        // Blocks until a datagram arrives; msg.from holds sender address
        const msg = try socket.receive(io, &buf);
        debug_print("Received {d} bytes from {}\n", .{ msg.data.len, msg.from });
        try socket.send(io, &msg.from, msg.data);
    }
}

socket.receive returns net.IncomingMessage with two fields:

  • msg.data - slice into your buffer containing the received bytes
  • msg.from - net.IpAddress of the sender, ready to pass straight back to send
05 - UDP CLIENT

UDP client

const std = @import("std");
const Io = std.Io;
const net = std.Io.net;

const print = std.debug.print;

pub fn main(init: std.process.Init) !void {
    const io = init.io;

    // Bind to port 0 - OS assigns an ephemeral port so we can receive the echo
    const local_addr = try net.IpAddress.parseIp4("0.0.0.0", 0);
    var socket = try local_addr.bind(io, .{ .mode = .dgram });
    defer socket.close(io);

    const server_addr = try net.IpAddress.parseIp4("127.0.0.1", 2101);
    try socket.send(io, &server_addr, "Hello from UDP client!\n");
    print("Sent to 127.0.0.1:2101\n", .{});

    var buf: [65536]u8 = undefined;
    const msg = try socket.receive(io, &buf);
    print("Echo: {s}", .{msg.data});
}

Bind to port 0 before sending - the OS assigns an ephemeral port, which lets the server's echo reach us. Sending without binding first would leave no return address for the reply.

06 - SUMMARY

Key takeaways

TCPUDP
Bind calladdr.listen(io, .{ .mode = .stream })addr.bind(io, .{ .mode = .dgram })
Returnsnet.Servernet.Socket
Accept / receiveserver.accept(io) -> net.Streamsocket.receive(io, &buf) -> net.IncomingMessage
Sendstream.writer + writeAll + flushsocket.send(io, &dest, data)
Closeserver.deinit(io) / stream.close(io)socket.close(io)
BUFFERED WRITER REQUIRES flush()

writeAll fills the internal write buffer. Nothing is sent to the socket until flush() is called. Forgetting flush() means silent data loss - the bytes sit in the buffer and are dropped when the stream closes.

Sources

Zig Source-Code
std.Io.net
fillMore
readSliceShort
readSliceAll
readAlloc
Code-Snippets