Learn Zig Series (#81) - UDP Sockets and Datagrams
Learn Zig Series (#81) - UDP Sockets and Datagrams
What will I learn
- How UDP differs from TCP at the protocol level and why you'd choose one over the other;
- How to create, bind, and use UDP sockets with Zig's standard library;
- How to send and receive datagrams with
sendtoandrecvfrom; - How to build a simple echo server and client using UDP;
- How to handle the realities of UDP -- packet loss, ordering, and message boundaries;
- How to implement a basic reliability layer on top of UDP with sequence numbers and acknowledgments;
- How to use
pollfor non-blocking UDP I/O with timeouts; - How Zig's error handling and type system make UDP programming safer than C.
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 (#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
- Learn Zig Series (#22) - Hash Maps and Data Structures
- Learn Zig Series (#23) - Iterators and Lazy Evaluation
- Learn Zig Series (#24) - Logging, Formatting, and Debug Output
- Learn Zig Series (#25) - Mini Project: HTTP Status Checker
- Learn Zig Series (#26) - Writing a Custom Allocator
- Learn Zig Series (#27) - C Interop: Calling C from Zig
- Learn Zig Series (#28) - C Interop: Exposing Zig to C
- Learn Zig Series (#29) - Inline Assembly and Low-Level Control
- Learn Zig Series (#30) - Thread Safety and Atomics
- Learn Zig Series (#31) - Memory-Mapped I/O and Files
- Learn Zig Series (#32) - Compile-Time Reflection with @typeInfo
- Learn Zig Series (#33) - Building a State Machine with Tagged Unions
- Learn Zig Series (#34) - Performance Profiling and Optimization
- Learn Zig Series (#35) - Cross-Compilation and Target Triples
- Learn Zig Series (#36) - Mini Project: CLI Task Runner
- Learn Zig Series (#37) - Markdown to HTML: Tokenizer and Lexer
- Learn Zig Series (#38) - Markdown to HTML: Parser and AST
- Learn Zig Series (#39) - Markdown to HTML: Renderer and CLI
- Learn Zig Series (#40) - Key-Value Store: In-Memory Store
- Learn Zig Series (#41) - Key-Value Store: Write-Ahead Log
- Learn Zig Series (#42) - Key-Value Store: TCP Server
- Learn Zig Series (#43) - Key-Value Store: Client Library and Benchmarks
- Learn Zig Series (#44) - Image Tool: Reading and Writing PPM/BMP
- Learn Zig Series (#45) - Image Tool: Pixel Operations
- Learn Zig Series (#46) - Image Tool: CLI Pipeline
- Learn Zig Series (#47) - Build a Shell: Parsing Commands
- Learn Zig Series (#48) - Build a Shell: Process Spawning
- Learn Zig Series (#49) - Build a Shell: Built-in Commands
- Learn Zig Series (#50) - Build a Shell: Job Control and Signals
- Learn Zig Series (#51) - HTTP Server: Accept Loop and Parsing
- Learn Zig Series (#52) - HTTP Server: Router and Responses
- Learn Zig Series (#53) - HTTP Server: Static Files and MIME
- Learn Zig Series (#54) - HTTP Server: Middleware and Logging
- Learn Zig Series (#55) - ECS Game Engine: Architecture
- Learn Zig Series (#56) - ECS Game Engine: Component Storage
- Learn Zig Series (#57) - ECS Game Engine: Systems and Queries
- Learn Zig Series (#58) - ECS Game Engine: Terminal Rendering
- Learn Zig Series (#59) - Assembler: Instruction Encoding
- Learn Zig Series (#60) - Assembler: Two-Pass Assembly
- Learn Zig Series (#61) - Assembler: Disassembler and Binary Inspector
- Learn Zig Series (#62) - File Systems: Reading Directories and Metadata
- Learn Zig Series (#63) - File Watching: Detecting Changes
- Learn Zig Series (#64) - Process Management: Fork, Exec, Wait
- Learn Zig Series (#65) - Pipes and Inter-Process Communication
- Learn Zig Series (#66) - Shared Memory and Semaphores
- Learn Zig Series (#67) - Signal Handling Deep Dive
- Learn Zig Series (#68) - Unix Domain Sockets
- Learn Zig Series (#69) - Daemonization: Background Services
- Learn Zig Series (#70) - Timers and Scheduling
- Learn Zig Series (#71) - Resource Limits and Capabilities
- Learn Zig Series (#72) - System Call Wrappers
- Learn Zig Series (#73) - seccomp and Sandboxing
- Learn Zig Series (#74) - ptrace: Process Tracing
- Learn Zig Series (#75) - Reading Kernel State from /proc and /sys
- Learn Zig Series (#76) - Mini Project: Process Monitor
- Learn Zig Series (#77) - Mini Project: File Sync Tool - Part 1
- Learn Zig Series (#78) - Mini Project: File Sync Tool - Part 2: Delta Transfer
- Learn Zig Series (#79) - Mini Project: File Sync Tool - Part 3: Network Protocol
- Learn Zig Series (#80) - Mini Project: File Sync Tool - Part 4: Polish
- Learn Zig Series (#81) - UDP Sockets and Datagrams (this post)
Learn Zig Series (#81) - UDP Sockets and Datagrams
Alright, new chapter! We just wrapped up the file sync mini-project where we built a complete TCP-based tool with delta transfers, authentication, conflict resolution, the works. Now we're starting the networking deep-dive section of this series, and we're kicking it off with the other big transport protocol: UDP.
Back in episode 21 we built a TCP echo server and learned how stream sockets work -- connection-oriented, ordered, reliable, the whole package. UDP is the polar opposite in almost every way. No connections, no ordering guarantees, no retransmission, no flow control. You just fire a datagram into the network and hope it arrives. Sounds terrible, right? And yet UDP carries most of the internet's real-time traffic: DNS lookups, video streaming, online gaming, VoIP, all the stuff where speed matters more than perfection.
Here we go -- let's figure out why that makes sense and build some actual UDP code in Zig ;-)
TCP vs UDP: the fundamental difference
Before writing any code, let's make sure we really understand what we're dealing with. Both TCP and UDP sit on top of IP (Internet Protocol), which handles routing packets between machines. The difference is what they add (or don't add) on top of IP.
TCP gives you a byte stream. You open a connection, write bytes into it, and TCP guarantees they arrive in order, without gaps, without duplicates. If a packet gets lost, TCP retransmits it. If packets arrive out of order, TCP reorders them. The cost: connection setup (three-way handshake), buffering, congestion control, and head-of-line blocking (one lost packet stalls everything behind it).
UDP gives you datagrams. You send a chunk of data (up to ~65,507 bytes per datagram on IPv4, though anything over ~1,400 bytes risks fragmentation) and that chunk either arrives intact or doesn't arrive at all. No partial delivery. No ordering. No connection state. Each datagram is independent.
// The key mental model difference:
//
// TCP (stream): write("hello") + write(" world") --> read() might return "hello world"
// TCP merges bytes, you parse boundaries yourself
//
// UDP (datagram): send("hello") + send(" world") --> recv() returns "hello"
// recv() returns " world"
// (or " world" first, or one gets lost entirely)
// Each send is one recv. Message boundaries preserved.
This message-boundary preservation is actually one of UDP's underrated features. With TCP you always need a framing protocol (length-prefix, delimiter, etc -- we built exactly that in episode 79). With UDP, each send maps to exactly one recv. The operating system delivers complete datagrams or nothing.
Creating a UDP socket in Zig
In episode 21 we used std.posix.socket with SOCK_STREAM for TCP. For UDP, we use SOCK_DGRAM:
const std = @import("std");
const posix = std.posix;
pub fn createUdpSocket() !posix.socket_t {
const sock = try posix.socket(
posix.AF.INET, // IPv4
posix.SOCK.DGRAM, // datagram socket = UDP
0, // protocol (0 = let OS pick, which will be UDP for DGRAM)
);
errdefer posix.close(sock);
// Allow address reuse -- helpful during development when you restart
// the server and the old port is still in TIME_WAIT
try posix.setsockopt(sock, posix.SOL.SOCKET, posix.SO.REUSEADDR, &std.mem.toBytes(@as(c_int, 1)));
return sock;
}
Notice: no listen(), no accept(). Those are TCP concepts. A UDP socket is ready to send and receive as soon as it's created. If you want to receive on a specific port, you bind() it, but you never "listen" for connections because there are no connections.
A basic UDP echo server
Here's the simplest useful UDP server -- it receives datagrams and sends them back to whoever sent them:
const std = @import("std");
const posix = std.posix;
pub fn main() !void {
const sock = try posix.socket(posix.AF.INET, posix.SOCK.DGRAM, 0);
defer posix.close(sock);
try posix.setsockopt(sock, posix.SOL.SOCKET, posix.SO.REUSEADDR, &std.mem.toBytes(@as(c_int, 1)));
// Bind to port 9000 on all interfaces
const addr = std.net.Address.initIp4(.{ 0, 0, 0, 0 }, 9000);
try posix.bind(sock, &addr.any, addr.getOsSockLen());
std.debug.print("UDP echo server listening on port 9000\n", .{});
var buf: [1024]u8 = undefined;
while (true) {
var client_addr: posix.sockaddr = undefined;
var addr_len: posix.socklen_t = @sizeOf(posix.sockaddr);
// recvfrom: receive a datagram and record where it came from
const n = try posix.recvfrom(sock, &buf, 0, &client_addr, &addr_len);
if (n == 0) continue;
const msg = buf[0..n];
std.debug.print("received {d} bytes: {s}\n", .{ n, msg });
// sendto: send the datagram back to the client's address
_ = try posix.sendto(sock, msg, 0, &client_addr, addr_len);
}
}
Compare this to the TCP echo server from episode 21. The TCP version needed listen(), accept() (which blocks until a client connects and returns a NEW socket), and then a read/write loop on that per-client socket. Here we have one socket that handles ALL clients. Every recvfrom call tells us who sent the datagram (via client_addr), and we use sendto to reply directly to that address. No per-client state, no connection tracking, no thread per client.
This is why UDP servers can handle massive numbers of "clients" with minimal resources. A DNS server answering 100,000 queries per second doesn't maintain 100,000 connections -- it just reads datagrams and sends replies on one socket.
The UDP client
The client side is equally straightforward:
const std = @import("std");
const posix = std.posix;
pub fn main() !void {
const sock = try posix.socket(posix.AF.INET, posix.SOCK.DGRAM, 0);
defer posix.close(sock);
const server_addr = std.net.Address.initIp4(.{ 127, 0, 0, 1 }, 9000);
const message = "Hello from UDP client!";
// sendto: send a datagram to the server
_ = try posix.sendto(
sock,
message,
0,
&server_addr.any,
server_addr.getOsSockLen(),
);
std.debug.print("sent: {s}\n", .{message});
// Wait for echo reply
var buf: [1024]u8 = undefined;
var src_addr: posix.sockaddr = undefined;
var addr_len: posix.socklen_t = @sizeOf(posix.sockaddr);
const n = try posix.recvfrom(sock, &buf, 0, &src_addr, &addr_len);
std.debug.print("received echo: {s}\n", .{buf[0..n]});
}
One thing to notice: the client doesn't call bind(). The OS automatically assigns an ephemeral port when you first call sendto. The server sees the client's source IP and source port in the client_addr from recvfrom and can reply to it. This is the same mechanism TCP uses for client ports, but with UDP you can see it more clearly because there's no connection handshake hiding the details.
Wrapping it up: a proper UdpSocket struct
The raw posix.sendto / posix.recvfrom calls work but they're verbose. Let's build a clean wrapper that handles the common operations:
const std = @import("std");
const posix = std.posix;
pub const UdpSocket = struct {
fd: posix.socket_t,
bound_addr: ?std.net.Address,
pub fn init() !UdpSocket {
const fd = try posix.socket(posix.AF.INET, posix.SOCK.DGRAM, 0);
errdefer posix.close(fd);
try posix.setsockopt(fd, posix.SOL.SOCKET, posix.SO.REUSEADDR, &std.mem.toBytes(@as(c_int, 1)));
return .{
.fd = fd,
.bound_addr = null,
};
}
pub fn deinit(self: *UdpSocket) void {
posix.close(self.fd);
self.fd = -1;
}
pub fn bind(self: *UdpSocket, address: std.net.Address) !void {
try posix.bind(self.fd, &address.any, address.getOsSockLen());
self.bound_addr = address;
}
pub const RecvResult = struct {
data: []const u8,
sender: std.net.Address,
};
/// Receive a datagram. Returns the data slice and the sender's address.
/// The data points into the provided buffer.
pub fn recv(self: *UdpSocket, buf: []u8) !RecvResult {
var src_addr: posix.sockaddr.storage = undefined;
var addr_len: posix.socklen_t = @sizeOf(posix.sockaddr.storage);
const n = try posix.recvfrom(
self.fd,
buf,
0,
@ptrCast(&src_addr),
&addr_len,
);
return .{
.data = buf[0..n],
.sender = std.net.Address.initPosix(@ptrCast(&src_addr)),
};
}
/// Send a datagram to the specified address.
pub fn sendTo(self: *UdpSocket, data: []const u8, dest: std.net.Address) !usize {
return posix.sendto(
self.fd,
data,
0,
&dest.any,
dest.getOsSockLen(),
);
}
/// Set a receive timeout. If no datagram arrives within `timeout_ms`
/// milliseconds, recv() will return error.WouldBlock.
pub fn setRecvTimeout(self: *UdpSocket, timeout_ms: u32) !void {
const tv = posix.timeval{
.sec = @intCast(timeout_ms / 1000),
.usec = @intCast((timeout_ms % 1000) * 1000),
};
try posix.setsockopt(
self.fd,
posix.SOL.SOCKET,
posix.SO.RCVTIMEO,
&std.mem.toBytes(tv),
);
}
/// Enable or disable broadcasting on this socket.
pub fn setBroadcast(self: *UdpSocket, enabled: bool) !void {
const val: c_int = if (enabled) 1 else 0;
try posix.setsockopt(
self.fd,
posix.SOL.SOCKET,
posix.SO.BROADCAST,
&std.mem.toBytes(val),
);
}
};
The RecvResult struct packages the received data together with the sender's address -- you always need both with UDP. The setRecvTimeout method is crucial for any real application because without it, recv blocks forever if no datagram arrives. We'll use that heavily when building reliability on top of UDP.
Dealing with reality: packet loss and ordering
Here's where UDP gets interesting (and tricky). With TCP, the network stack handles all the messy stuff for you. With UDP, you're on your own. Let's build a small test that demonstrates what actually happens:
const std = @import("std");
const posix = std.posix;
const UdpSocket = @import("udp.zig").UdpSocket;
/// Send numbered datagrams rapidly and see what the receiver gets.
/// This demonstrates UDP's lack of ordering and reliability guarantees.
pub fn packetLossDemo() !void {
var sender = try UdpSocket.init();
defer sender.deinit();
var receiver = try UdpSocket.init();
defer receiver.deinit();
const recv_addr = std.net.Address.initIp4(.{ 127, 0, 0, 1 }, 9500);
try receiver.bind(recv_addr);
// Reduce receive buffer to increase chance of drops on localhost
const small_buf: c_int = 4096;
try posix.setsockopt(
receiver.fd,
posix.SOL.SOCKET,
posix.SO.RCVBUF,
&std.mem.toBytes(small_buf),
);
// Send 1000 numbered datagrams as fast as possible
var send_buf: [64]u8 = undefined;
for (0..1000) |i| {
const msg = std.fmt.bufPrint(&send_buf, "packet-{d:0>4}", .{i}) catch continue;
_ = sender.sendTo(msg, recv_addr) catch continue;
}
// Now read everything that arrived
try receiver.setRecvTimeout(500); // 500ms timeout
var recv_buf: [64]u8 = undefined;
var received: u32 = 0;
var out_of_order: u32 = 0;
var last_seq: i32 = -1;
while (true) {
const result = receiver.recv(&recv_buf) catch break;
const seq_str = result.data[7..11]; // "packet-NNNN"
const seq = std.fmt.parseInt(u32, seq_str, 10) catch continue;
if (@as(i32, @intCast(seq)) <= last_seq) {
out_of_order += 1;
}
last_seq = @intCast(seq);
received += 1;
}
std.debug.print("sent: 1000, received: {d}, lost: {d}, out of order: {d}\n", .{
received,
1000 - received,
out_of_order,
});
}
On localhost you'll typically see all 1000 packets arrive (the loopback interface is very reliable) unless you deliberately shrink the receive buffer. Over a real network, especially under load or with congestion, you'll start seeing drops. The important thing: your code needs to handle this gracefully. Never assume a UDP packet will arrive.
Building a reliability layer: sequence numbers and ACKs
Sometimes you want UDP's low latency but you also need to know if a message was received. The standard approach is to add a thin reliability layer on top: sequence numbers, acknowledgements, and retransmission with timeouts. This is basically rebuilding parts of TCP yourself -- but only the parts you need.
const std = @import("std");
const posix = std.posix;
const UdpSocket = @import("udp.zig").UdpSocket;
pub const ReliableHeader = packed struct {
seq: u32, // sequence number
ack: u32, // acknowledgement number
flags: u8, // bit 0: is_ack, bit 1: is_data
payload_len: u16, // length of payload after header
};
pub const ReliableUdp = struct {
socket: UdpSocket,
next_seq: u32,
send_buf: [1400]u8, // MTU-safe maximum
recv_buf: [1500]u8,
retransmit_timeout_ms: u32,
max_retries: u32,
pub fn init(socket: UdpSocket) ReliableUdp {
return .{
.socket = socket,
.next_seq = 0,
.send_buf = undefined,
.recv_buf = undefined,
.retransmit_timeout_ms = 200,
.max_retries = 5,
};
}
/// Send data reliably: transmit, wait for ACK, retransmit if needed.
pub fn sendReliable(
self: *ReliableUdp,
data: []const u8,
dest: std.net.Address,
) !void {
if (data.len > 1400 - @sizeOf(ReliableHeader)) {
return error.PayloadTooLarge;
}
const seq = self.next_seq;
self.next_seq += 1;
// Build the packet: header + payload
const header = ReliableHeader{
.seq = seq,
.ack = 0,
.flags = 0x02, // is_data
.payload_len = @intCast(data.len),
};
const hdr_bytes = std.mem.asBytes(&header);
@memcpy(self.send_buf[0..hdr_bytes.len], hdr_bytes);
@memcpy(self.send_buf[hdr_bytes.len..][0..data.len], data);
const total_len = hdr_bytes.len + data.len;
const packet = self.send_buf[0..total_len];
// Retry loop: send, wait for ACK, resend if timeout
var attempt: u32 = 0;
while (attempt < self.max_retries) : (attempt += 1) {
_ = try self.socket.sendTo(packet, dest);
// Wait for ACK
try self.socket.setRecvTimeout(self.retransmit_timeout_ms);
const result = self.socket.recv(&self.recv_buf) catch |err| {
if (err == error.WouldBlock) {
// Timeout -- retransmit
continue;
}
return err;
};
// Check if it's an ACK for our sequence number
if (result.data.len >= @sizeOf(ReliableHeader)) {
const resp_hdr: *const ReliableHeader = @ptrCast(@alignCast(result.data.ptr));
if (resp_hdr.flags & 0x01 != 0 and resp_hdr.ack == seq) {
return; // ACK received, success!
}
}
}
return error.SendFailed; // all retries exhausted
}
/// Receive data and automatically send an ACK back.
pub fn recvReliable(
self: *ReliableUdp,
out_buf: []u8,
) !struct { data: []const u8, sender: std.net.Address } {
while (true) {
const result = try self.socket.recv(&self.recv_buf);
if (result.data.len < @sizeOf(ReliableHeader)) continue;
const hdr: *const ReliableHeader = @ptrCast(@alignCast(result.data.ptr));
// Skip ACK packets -- we only want data
if (hdr.flags & 0x02 == 0) continue;
// Send ACK
const ack_hdr = ReliableHeader{
.seq = 0,
.ack = hdr.seq,
.flags = 0x01, // is_ack
.payload_len = 0,
};
_ = try self.socket.sendTo(std.mem.asBytes(&ack_hdr), result.sender);
// Copy payload to output buffer
const payload_start = @sizeOf(ReliableHeader);
const payload_end = payload_start + hdr.payload_len;
if (payload_end > result.data.len) continue;
const payload = result.data[payload_start..payload_end];
if (payload.len > out_buf.len) return error.BufferTooSmall;
@memcpy(out_buf[0..payload.len], payload);
return .{
.data = out_buf[0..payload.len],
.sender = result.sender,
};
}
}
};
This is a stop-and-wait protocol -- the simplest possible reliable protocol. Send one packet, wait for the ACK, then send the next. It works but it's slow because you can only have one packet "in flight" at a time. Real protocols like QUIC use a sliding window where you send multiple packets before waiting for ACKs, which is much faster but also much more complex.
The packed struct for the header (we covered packed structs in episode 17) maps directly to the wire format. The flags byte uses individual bits for different purposes -- bit 0 for "this is an ACK", bit 1 for "this carries data". You could combine them (a data packet that also acknowledges a previous packet), which is called piggybacking and it's exactly what TCP does.
Having said that, for most use cases you're better off using an existing library like QUIC rather than rolling your own reliability layer. But understanding how it works underneath is valuable -- you'll know what's happening when your QUIC connection stalls on a congested link.
Non-blocking I/O with poll
Our current server blocks on recvfrom until a datagram arrives. For anything beyond a toy example, you need non-blocking I/O. The poll system call (which we touched on when building the TCP server in episode 51) works with UDP sockets too:
const std = @import("std");
const posix = std.posix;
pub const UdpPoller = struct {
fds: [1]posix.pollfd,
pub fn init(sock_fd: posix.socket_t) UdpPoller {
return .{
.fds = .{.{
.fd = sock_fd,
.events = posix.POLL.IN,
.revents = 0,
}},
};
}
/// Wait for a datagram to be available, with timeout.
/// Returns true if data is ready, false on timeout.
pub fn waitForData(self: *UdpPoller, timeout_ms: i32) !bool {
const result = try posix.poll(&self.fds, timeout_ms);
return result > 0 and (self.fds[0].revents & posix.POLL.IN != 0);
}
};
/// Example: UDP server with periodic housekeeping
pub fn pollBasedServer() !void {
var sock = @import("udp.zig").UdpSocket.init() catch return;
defer sock.deinit();
const addr = std.net.Address.initIp4(.{ 0, 0, 0, 0 }, 9000);
try sock.bind(addr);
var poller = UdpPoller.init(sock.fd);
var buf: [1024]u8 = undefined;
var total_packets: u64 = 0;
var last_stats = std.time.milliTimestamp();
while (true) {
// Wait up to 1 second for data
const ready = try poller.waitForData(1000);
if (ready) {
const result = try sock.recv(&buf);
total_packets += 1;
// Echo it back
_ = try sock.sendTo(result.data, result.sender);
}
// Periodic stats every 5 seconds
const now = std.time.milliTimestamp();
if (now - last_stats >= 5000) {
std.debug.print("stats: {d} packets handled\n", .{total_packets});
last_stats = now;
}
}
}
The key advantage of poll over blocking reads: you can do other things while waiting. Print stats, check timers, clean up stale state, whatever your application needs. The 1-second timeout means the loop runs at least once per second even if no packets arrive. For the file sync tool we built in episodes 77-80, using poll with UDP would have let us handle multiple clients on a single thread without the complexity of spawning threads per connection (which is what we did with TCP).
Multicast: sending to many receivers
One unique capability of UDP that TCP simply cannot do is multicast -- sending a single datagram that gets delivered to multiple receivers simultaneously. The network infrastructure handles the duplication at the router level so the sender only transmits once regardless of how many receivers there are:
const std = @import("std");
const posix = std.posix;
pub const MulticastGroup = struct {
sock: posix.socket_t,
group_addr: [4]u8,
/// Join a multicast group on a UDP socket.
/// group: multicast address (e.g. 239.1.1.1)
/// interface: local interface address (0.0.0.0 for default)
pub fn join(sock: posix.socket_t, group: [4]u8, interface_addr: [4]u8) !MulticastGroup {
const mreq = extern struct {
multiaddr: [4]u8,
interface: [4]u8,
}{
.multiaddr = group,
.interface = interface_addr,
};
try posix.setsockopt(
sock,
posix.IPPROTO.IP,
@as(u32, 12), // IP_ADD_MEMBERSHIP
&std.mem.toBytes(mreq),
);
return .{
.sock = sock,
.group_addr = group,
};
}
/// Leave the multicast group
pub fn leave(self: *MulticastGroup, interface_addr: [4]u8) void {
const mreq = extern struct {
multiaddr: [4]u8,
interface: [4]u8,
}{
.multiaddr = self.group_addr,
.interface = interface_addr,
};
posix.setsockopt(
self.sock,
posix.IPPROTO.IP,
@as(u32, 13), // IP_DROP_MEMBERSHIP
&std.mem.toBytes(mreq),
) catch {};
}
};
/// Example: multicast sender
pub fn multicastSend() !void {
const sock = try posix.socket(posix.AF.INET, posix.SOCK.DGRAM, 0);
defer posix.close(sock);
// Set TTL for multicast packets (1 = local network only)
const ttl: c_int = 1;
try posix.setsockopt(
sock,
posix.IPPROTO.IP,
@as(u32, 10), // IP_MULTICAST_TTL
&std.mem.toBytes(ttl),
);
const group = std.net.Address.initIp4(.{ 239, 1, 1, 1 }, 5000);
const msg = "hello multicast group!";
_ = try posix.sendto(sock, msg, 0, &group.any, group.getOsSockLen());
std.debug.print("sent multicast message\n", .{});
}
Multicast addresses live in the range 224.0.0.0 -- 239.255.255.255. The range 239.x.x.x is reserved for "administratively scoped" multicast (local organization use), which is what you'd use for things like service discovery on a LAN. Video streaming services use different ranges depending on their setup.
The TTL (Time To Live) controls how far multicast packets travel. TTL=1 means they stay on the local network segment. TTL=32 means they can cross up to 32 routers. For most development work you want TTL=1 to avoid accidentally flooding your network ;-)
When to use UDP vs TCP
After building both, here's my practical guideline for choosing:
Use TCP when:
- You need every byte delivered, in order
- You're transferring files or structured data where a missing chunk breaks everything
- Latency isn't your primary concern
- You want the OS to handle congestion control
Use UDP when:
- Real-time matters more than reliability (gaming, VoIP, live video)
- You're building request/response patterns (DNS, service discovery)
- You need multicast or broadcast
- You're sending small independent messages that don't depend on each other
- You want to build your own congestion control (QUIC does this)
The hybrid approach is common too -- use TCP for control traffic (login, configuration, chat) and UDP for the data plane (audio, video, game state). Most online games work exactly like that.
Testing UDP code
Testing networked code is always a bit awkward. You need two endpoints, timing matters, and you're dealing with the OS kernel's network stack. Here's a test helper that spins up a server and client on localhost:
const std = @import("std");
const UdpSocket = @import("udp.zig").UdpSocket;
test "udp echo round trip" {
// Server socket
var server = try UdpSocket.init();
defer server.deinit();
const server_addr = std.net.Address.initIp4(.{ 127, 0, 0, 1 }, 0); // port 0 = OS picks
try server.bind(server_addr);
// Get the actual port the OS assigned
var bound: posix.sockaddr.storage = undefined;
var bound_len: posix.socklen_t = @sizeOf(posix.sockaddr.storage);
try posix.getsockname(server.fd, @ptrCast(&bound), &bound_len);
const actual_addr = std.net.Address.initPosix(@ptrCast(&bound));
// Client socket
var client = try UdpSocket.init();
defer client.deinit();
// Send from client to server
const msg = "test message";
_ = try client.sendTo(msg, actual_addr);
// Receive on server
var buf: [256]u8 = undefined;
try server.setRecvTimeout(1000);
const result = try server.recv(&buf);
try std.testing.expectEqualStrings("test message", result.data);
}
test "udp timeout fires" {
var sock = try UdpSocket.init();
defer sock.deinit();
const addr = std.net.Address.initIp4(.{ 127, 0, 0, 1 }, 0);
try sock.bind(addr);
try sock.setRecvTimeout(100); // 100ms timeout
var buf: [256]u8 = undefined;
const err = sock.recv(&buf);
try std.testing.expectError(error.WouldBlock, err);
}
The trick of binding to port 0 lets the OS pick an unused port, which prevents test failures from port conflicts. We then use getsockname to find out which port was assigned. This is a pattern you'll use constantly when testing networking code -- never hardcode ports in tests.
Exercises
Build a UDP chat application with two programs: a sender and a receiver. The sender reads lines from stdin and sends each line as a UDP datagram to a configurable address:port. The receiver binds to a port, prints every datagram it receives along with the sender's IP and port. Test it by running both on localhost. Bonus: make it bidirectional so both sides can send and receive.
Implement a UDP ping tool that sends a timestamped datagram to a target, waits for the echo, and measures the round-trip time in microseconds. Send 10 pings with 1-second intervals and print min/avg/max statistics at the end. Handle the case where a reply never arrives (timeout after 2 seconds) and report it as "lost".
Build a simple service discovery protocol: write a "discoverer" program that sends a broadcast datagram containing
"DISCOVER"to the subnet broadcast address (255.255.255.255) on a known port, and a "responder" program that listens on that port and replies with its hostname and the services it offers (just hardcode a few like"http:8080,ssh:22"). The discoverer should collect responses for 2 seconds and print all discovered services.
Bedankt en tot de volgende keer!
Leave Learn Zig Series (#81) - UDP Sockets and Datagrams to:
Read more #stem posts
Best Posts From scipio
We have not curated any of scipio's posts yet. But you can encourage our curation team to review posts by visiting them regularly and by referring other readers. Because we give priority to frequently read content.
More Posts From scipio
- Learn Rust Series (#12) - Smart Pointers: Box, Rc & RefCell
- Learn AI Series (#142) - AI Reasoning and Planning
- Learn Zig Series (#122) - Union-Find
- Learn Rust Series (#11) - Closures & the Iterator Trait
- Learn AI Series (#141) - Robotics and Embodied AI
- Learn Zig Series (#121) - Topological Sort
- Learn Rust Series (#10) - Lifetimes
- Learn AI Series (#140) - Scientific AI
- Learn Zig Series (#120) - Dijkstra and A*
- Learn Rust Series (#9) - Modules & Crates