UDP & Datagrams in C: High-Speed Connectionless Networking, Broadcasting & QUIC

UDP & Datagrams in C: High-Speed Connectionless Networking, Broadcasting & QUIC
Table of Contents
- TCP vs UDP: The Fundamental Trade-off
- UDP Socket Lifecycle: No Connection State
- Building a UDP Echo Server
- UDP Client: sendto and recvfrom
- Broadcasting: Sending to All Hosts on a Network
- Multicasting: Selective Group Communication
- Implementing Reliable UDP: Sequence Numbers and ACKs
- UDP for Real-Time Games: The State Snapshot Model
- DNS: UDP in Production (Port 53)
- QUIC: The Future of UDP (HTTP/3)
- Frequently Asked Questions
- Key Takeaway
TCP vs UDP: The Fundamental Trade-off
| Application | Protocol | Reason |
|---|---|---|
| Web (HTTP/1.1, HTTP/2) | TCP | Reliability critical |
| DNS lookups | UDP | Single request-response; fast |
| Video streaming (HLS/DASH) | TCP | Buffered; stall is OK |
| Live video (WebRTC, RTSP) | UDP | 100ms latency budget |
| Online gaming (position) | UDP | Stale packets worthless |
| VoIP (voice call) | UDP | Jitter tolerated; gaps OK |
| HTTP/3 (QUIC) | UDP | Custom reliability on UDP |
| DHCP | UDP | Broadcast; client has no IP yet |
UDP Socket Lifecycle: No Connection State
Unlike TCP, UDP has no connection establishment or teardown. The simplified lifecycle:
socket() → bind() → sendto()/recvfrom() → close()No listen(), no accept(), no connect() (optional for "connected" UDP). This simplicity is what makes UDP fast — there's no state machine to maintain in the kernel.
Building a UDP Echo Server
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <stdio.h>
#include <string.h>
#define UDP_PORT 9999
#define BUFSIZE 8192
int main(void) {
// Step 1: Create UDP socket
int udp_fd = socket(AF_INET, SOCK_DGRAM, 0);
if (udp_fd < 0) { perror("socket"); return 1; }
// Step 2: Allow port reuse
int opt = 1;
setsockopt(udp_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
// Step 3: Bind to port
struct sockaddr_in server_addr = {
.sin_family = AF_INET,
.sin_port = htons(UDP_PORT),
.sin_addr.s_addr = INADDR_ANY,
};
if (bind(udp_fd, (struct sockaddr*)&server_addr, sizeof(server_addr)) < 0) {
perror("bind"); close(udp_fd); return 1;
}
printf("UDP Echo Server listening on port %d\n", UDP_PORT);
// Step 4: Receive-process-respond loop (no accept() needed!)
while (1) {
char buffer[BUFSIZE];
struct sockaddr_in client_addr;
socklen_t client_len = sizeof(client_addr);
// recvfrom: receive datagram AND capture sender's address
ssize_t n = recvfrom(udp_fd, buffer, sizeof(buffer) - 1, 0,
(struct sockaddr*)&client_addr, &client_len);
if (n < 0) { perror("recvfrom"); continue; }
buffer[n] = '\0';
char client_ip[INET_ADDRSTRLEN];
inet_ntop(AF_INET, &client_addr.sin_addr, client_ip, sizeof(client_ip));
printf("From %s:%d: %s\n", client_ip, ntohs(client_addr.sin_port), buffer);
// Echo back to the same client
sendto(udp_fd, buffer, n, 0,
(struct sockaddr*)&client_addr, client_len);
}
close(udp_fd);
return 0;
}UDP Client: sendto and recvfrom
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <stdio.h>
#include <string.h>
int main(void) {
int sockfd = socket(AF_INET, SOCK_DGRAM, 0);
if (sockfd < 0) { perror("socket"); return 1; }
struct sockaddr_in server_addr = {
.sin_family = AF_INET,
.sin_port = htons(9999),
.sin_addr.s_addr = inet_addr("127.0.0.1"),
};
const char *message = "Hello from UDP client!";
// Send datagram to server
ssize_t sent = sendto(sockfd, message, strlen(message), 0,
(struct sockaddr*)&server_addr, sizeof(server_addr));
printf("Sent %zd bytes\n", sent);
// Wait for response (with 5-second timeout)
struct timeval tv = { .tv_sec = 5, .tv_usec = 0 };
setsockopt(sockfd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv));
char response[8192];
struct sockaddr_in from_addr;
socklen_t from_len = sizeof(from_addr);
ssize_t n = recvfrom(sockfd, response, sizeof(response) - 1, 0,
(struct sockaddr*)&from_addr, &from_len);
if (n < 0) {
perror("recvfrom (timeout?)");
} else {
response[n] = '\0';
printf("Response: %s\n", response);
}
close(sockfd);
return 0;
}Broadcasting: Sending to All Hosts on a Network
UDP broadcasting sends a single packet to all hosts on a subnet simultaneously:
#include <sys/socket.h>
#include <netinet/in.h>
#include <unistd.h>
#include <stdio.h>
#include <string.h>
void broadcast_service_announcement(uint16_t port) {
int sockfd = socket(AF_INET, SOCK_DGRAM, 0);
// Must explicitly enable broadcast permission
int broadcast_enable = 1;
setsockopt(sockfd, SOL_SOCKET, SO_BROADCAST, &broadcast_enable, sizeof(broadcast_enable));
// Broadcast address: last octet = 255 (subnet broadcast)
// Or 255.255.255.255 for limited broadcast (all hosts on LAN)
struct sockaddr_in broadcast_addr = {
.sin_family = AF_INET,
.sin_port = htons(port),
.sin_addr.s_addr = htonl(INADDR_BROADCAST), // 255.255.255.255
};
const char *announcement = "SERVICE:MyApp:v1.2:8080";
// One packet → ALL hosts on local network receive it
sendto(sockfd, announcement, strlen(announcement), 0,
(struct sockaddr*)&broadcast_addr, sizeof(broadcast_addr));
printf("Broadcast sent: %s\n", announcement);
close(sockfd);
}Applications: DHCP (clients broadcast to find a DHCP server), UPnP device discovery, NetBIOS name resolution, Wake-on-LAN packets.
Multicasting: Selective Group Communication
Multicast is more targeted than broadcast — only hosts that have subscribed to the multicast group receive the packets. IPv4 multicast addresses are in the 224.0.0.0/4 range:
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
// Receiver: join a multicast group
void multicast_receiver(uint16_t port) {
int sockfd = socket(AF_INET, SOCK_DGRAM, 0);
int reuse = 1;
setsockopt(sockfd, SOL_SOCKET, SO_REUSEADDR, &reuse, sizeof(reuse));
struct sockaddr_in addr = {
.sin_family = AF_INET,
.sin_port = htons(port),
.sin_addr.s_addr = INADDR_ANY,
};
bind(sockfd, (struct sockaddr*)&addr, sizeof(addr));
// Join multicast group 239.0.0.1
struct ip_mreq group = {
.imr_multiaddr.s_addr = inet_addr("239.0.0.1"),
.imr_interface.s_addr = INADDR_ANY,
};
setsockopt(sockfd, IPPROTO_IP, IP_ADD_MEMBERSHIP, &group, sizeof(group));
// Now receive multicast packets
char buf[4096];
recvfrom(sockfd, buf, sizeof(buf), 0, NULL, NULL);
printf("Multicast: %s\n", buf);
}Multicast is used by: streaming protocols (IPTV, live sports), financial market data feeds (Bloomberg, Nasdaq quote dissemination), and cluster node discovery (etcd, Consul).
Implementing Reliable UDP: Sequence Numbers and ACKs
When you need UDP's speed but TCP's reliability, implement it yourself:
#include <stdint.h>
#include <stdbool.h>
// Custom reliable UDP message header
typedef struct __attribute__((packed)) {
uint32_t magic; // 0xDEADBEEF — validate this is our protocol
uint32_t sequence; // Incrementing sequence number
uint32_t ack; // Last sequence number received (for flow control)
uint16_t msg_type; // 0=DATA, 1=ACK, 2=RESEND-REQUEST
uint16_t checksum; // Simple checksum to detect corruption
uint16_t payload_len; // Payload size in bytes
} ReliableUDPHeader;
// Simple sender state
typedef struct {
uint32_t next_seq; // Next sequence number to send
uint32_t unacked_seq; // Oldest unacknowledged sequence
ReliableUDPHeader *unacked_pkt; // Packet waiting for ACK
// In production: ring buffer of unacknowledged packets
} ReliableUDPSender;This is essentially what QUIC (the protocol behind HTTP/3) does at a much more sophisticated level — connection multiplexing, congestion control, and encryption all implemented on top of UDP at the application layer.
UDP for Real-Time Games: The State Snapshot Model
Online games like first-person shooters use UDP because a lost position packet is irrelevant — the next packet (arriving 16ms later) will have newer data:
typedef struct {
uint32_t sequence; // Monotonically increasing
uint32_t timestamp_ms; // Server timestamp for jitter compensation
uint8_t player_id;
float pos_x, pos_y, pos_z; // Current position (not delta)
float vel_x, vel_y, vel_z; // Current velocity
float aim_yaw, aim_pitch; // Camera orientation
uint32_t input_flags; // Button states as bit flags
} GameStatePacket;
// Client sends state 60 times/second over UDP
// Server uses the sequence number to detect and discard out-of-order packets
// Missing packets are simply ignored — render uses latest received stateThe key insight: game state packets contain the full current state, not a delta. If a packet is lost, the next packet restores full state — no need to retransmit the lost one.
DNS: UDP in Production (Port 53)
DNS uses UDP for most queries:
- Query packet: ~40-60 bytes typically.
- Response packet: ~100-500 bytes for most lookups.
- If response > 512 bytes, DNS falls back to TCP.
- The entire round trip (query + response) costs one UDP exchange — no TCP handshake.
// Simplified DNS query packet structure (RFC 1035)
typedef struct __attribute__((packed)) {
uint16_t id; // Query ID — matched with response
uint16_t flags; // QR=0 (query), OPCODE, AA, TC, RD, RA, Z, RCODE
uint16_t qdcount; // Number of questions
uint16_t ancount; // Number of answers (0 in query)
uint16_t nscount; // Number of authority records
uint16_t arcount; // Number of additional records
// Followed by variable-length question section
} DNSHeader;QUIC: The Future of UDP (HTTP/3)
QUIC, standardized as RFC 9000 and implemented by Google, Cloudflare, and all major browsers, multiplexes HTTP streams over UDP:
- No head-of-line blocking: In HTTP/2 over TCP, one lost packet blocks all streams. In QUIC, each stream independently handles retransmission.
- 0-RTT connection: QUIC combines TLS handshake with connection establishment — first request can be sent in 0 round trips for known servers.
- Connection migration: QUIC connections survive IP address changes (e.g., switching from Wi-Fi to 4G) because they're identified by a connection ID, not by (source IP, source port).
QUIC's entire reliability, ordering, and multiplexing layer is implemented in C/C++ at the application layer, on top of raw UDP sockets.
Frequently Asked Questions
When should I choose UDP over TCP? Use UDP when: latency is more important than reliability (live video, gaming, trading), the application-level data naturally overwrites stale data (position updates, sensor readings), you're implementing a custom protocol that already handles reliability (QUIC, SRTP, RTP), or you need broadcast/multicast (service discovery, DHCP).
Can UDP accidentally deliver duplicate packets? Yes — UDP does not filter duplicate packets. Network equipment can duplicate packets, and routers may send a packet on multiple paths. Your application protocol must handle duplicates by checking the sequence number.
Is UDP faster than TCP for large file transfers? Usually not — TCP's congestion control and buffer management are highly optimized for throughput. Tools like iPerf3 consistently show TCP matching or exceeding naive UDP for bulk transfers. UDP only wins for latency-sensitive workloads where TCP's retransmission delays are unacceptable.
What is the maximum size of a UDP datagram? The maximum UDP payload is 65,507 bytes (16-bit length field minus IP and UDP headers). However, packets larger than the network's MTU (~1,472 bytes for typical Ethernet after headers) will be fragmented at the IP layer, reducing efficiency. Production UDP applications typically limit to 1,400 bytes per datagram.
Key Takeaway
UDP is the Express Lane of Networking. Its connectionless model, no-handshake simplicity, and broadcast/multicast capabilities make it indispensable for real-time systems where TCP's reliability overhead is unacceptable. By implementing reliable messaging, ordering, and congestion control yourself — as QUIC does — you get the best of both worlds.
Read next: C23 Modern Evolution: auto, nullptr, constexpr →
Part of the C Mastery Course — 30 modules from C basics to expert systems engineering.
