Networking · lesson 04

UDP: datagrams

UDP is the thinnest thing you can put on top of IP: a port number, a length, a checksum. One sendto call becomes one packet. Whether that packet arrives, and in what order, is not UDP's problem — it is yours.

in progress
12 min interactive

You sent three messages. Two arrived, backwards.

You write a tiny program. It calls sendto three times with "HELLO", "WORLD!!", and "BYE". Every call returns the number of bytes you passed. No error. On the other machine, recvfrom returns "BYE", then "HELLO", then blocks forever. "WORLD!!" never shows up.

Nothing is broken. This is UDP doing exactly what it promises, which is almost nothing. The return value of sendto means one thing: the kernel copied your bytes into a packet and handed the packet to the network interface. That is where UDP's responsibility ends.

A datagram is a packet you can see

In the first lesson you built a frame by hand: payload, 8-byte UDP header, 20-byte IP header, Ethernet header. That 8-byte header is the entire protocol. Source port, destination port, length, checksum. Nothing else. There is no field for "which message is this" and no field for "did you get the last one". The protocol cannot retransmit because it has no way to know anything was lost.

So a UDP socket is a very direct thing. socket(AF_INET, SOCK_DGRAM, 0) gives you an fd whose kernel object has a receive buffer and almost no state. Each sendto becomes exactly one IP packet. Each arriving packet becomes exactly one recvfrom. The packet is the unit, and the unit is called a datagram.

💡
A datagram is a letter, not a phone call. You write the address on every one. Each is carried independently. Two letters posted a second apart can arrive in either order, and the post office does not tell you when one is lost.

Message boundaries are kept

This is the one guarantee UDP does make, and it is a useful one. If you sendto 5 bytes, the receiver's recvfrom returns those 5 bytes and nothing else. It will not return 3 of them. It will not glue them to the next datagram. The boundary you created when you called sendto is the boundary the receiver sees.

That boundary comes from the length field in the header. The kernel reads it, knows exactly where the datagram ends, and queues it as one unit in the socket's receive buffer. Your recvfrom dequeues one unit. The visualization shows all of this: three datagrams, one dropped on the way, the two survivors arriving in the wrong order, each one coming out whole.

udp — datagrams in flight
Click next to start the walkthrough.

What UDP does not do

  • No connection. There is no handshake. The first packet you send is data. The remote port can be closed; you may get an ICMP error back, or nothing at all.
  • No ordering. Two datagrams can take different routes. The second can arrive first.
  • No delivery guarantee. A congested router drops the packet and nobody is told. sendto has already returned success.
  • No congestion control. You can send as fast as you like. The network will drop what it cannot carry, and it will drop other people's traffic too.
  • No duplicate suppression. Rare, but a datagram can arrive twice.

If your program needs any of those properties, you build them yourself on top of UDP: sequence numbers in your payload, acknowledgements, retransmit timers. That sounds like a lot of work, and it is. It is also exactly what the next protocol you will meet does for you. The reason to choose UDP anyway is that sometimes you want different rules than TCP's.

The truncation trap

recvfrom(fd, buf, 8, ...) on a 16-byte datagram copies 8 bytes into buf, returns 8, and throws the other 8 away. They are not waiting in the buffer for your next call. The next call returns the next datagram. There is no error. The only hint is the MSG_TRUNC flag in msg_flags if you use recvmsg, which almost nobody does.

⚠️
Always receive into a buffer at least as large as the biggest datagram you expect. For most protocols that is the MTU: a 1500-byte Ethernet frame minus 20 bytes of IP header and 8 of UDP header leaves 1472 bytes of payload. Send more than that and the IP layer fragments the packet; if any fragment is lost, the whole datagram is lost. Programs that care about reliability stay under 1472, and often well under it.

An echo server and client

A UDP server has no listen, no accept, and no per-client socket. One fd, bound to one port, receives from everybody. The address of each sender comes back in the recvfrom out-parameter, and you reply to it with sendto. That is the whole server.

c · udp_echo_server.c
#include <arpa/inet.h> #include <stdio.h> #include <string.h> #include <sys/socket.h> #include <unistd.h> int main(void) { int fd = socket(AF_INET, SOCK_DGRAM, 0); if (fd == -1) { perror("socket"); return 1; } struct sockaddr_in me = { 0 }; me.sin_family = AF_INET; me.sin_port = htons(9000); me.sin_addr.s_addr = htonl(INADDR_ANY); // 0.0.0.0 if (bind(fd, (struct sockaddr *)&me, sizeof me) == -1) { perror("bind"); return 1; } // 1472 = one full datagram, no fragmentation char buf[1472]; for (;;) { struct sockaddr_in peer; socklen_t plen = sizeof peer; ssize_t n = recvfrom(fd, buf, sizeof buf, 0, (struct sockaddr *)&peer, &plen); if (n == -1) { perror("recvfrom"); continue; } char ip[INET_ADDRSTRLEN]; inet_ntop(AF_INET, &peer.sin_addr, ip, sizeof ip); printf("%zd bytes from %s:%u\n", n, ip, ntohs(peer.sin_port)); // echo it straight back to whoever sent it sendto(fd, buf, n, 0, (struct sockaddr *)&peer, plen); } }

The client is the mirror image. It does not bind: the kernel assigns an ephemeral port on the first sendto, and the server's reply comes back to it. Notice the client has to decide for itself how long to wait, because nothing in UDP will ever tell it the datagram was lost.

c · udp_echo_client.c
#include <arpa/inet.h> #include <stdio.h> #include <string.h> #include <sys/socket.h> #include <sys/time.h> #include <unistd.h> int main(void) { int fd = socket(AF_INET, SOCK_DGRAM, 0); // give up on a reply after 2 s — // UDP will never tell us the datagram was lost struct timeval tv = { .tv_sec = 2 }; setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof tv); struct sockaddr_in srv = { 0 }; srv.sin_family = AF_INET; srv.sin_port = htons(9000); inet_pton(AF_INET, "203.0.113.9", &srv.sin_addr); const char *msg = "HELLO"; sendto(fd, msg, strlen(msg), 0, (struct sockaddr *)&srv, sizeof srv); char buf[1472]; ssize_t n = recvfrom(fd, buf, sizeof buf, 0, NULL, NULL); if (n == -1) { perror("recvfrom (lost?)"); return 1; } printf("echo: %.*s\n", (int)n, buf); close(fd); }
✅
Every UDP program needs a timeout or a retry, and usually both. SO_RCVTIMEO makes a blocking recvfrom fail with EAGAIN after the interval. Without it, one lost datagram leaves your program asleep forever. Treat "no reply" as a normal outcome, not an exception.

Who wants this?

UDP looks like the bad option until you notice who uses it. DNS: one question, one answer, a connection would cost more than the lookup. Games and voice: a late packet is useless, so retransmitting it is worse than dropping it. QUIC, the transport under HTTP/3, runs over UDP so it can implement its own reliability without the kernel's TCP in the way.

And VPNs. WireGuard carries encrypted IP packets inside UDP datagrams. The packets it carries are already somebody else's TCP or UDP, with their own retransmission. Wrapping them in a second reliable layer would mean retransmitting retransmissions. The datagram model, one inner packet per outer packet, is the honest fit. When you build a mesh VPN, this is the socket you will build it on.

one-line takeaway

One sendto is one datagram and one recvfrom returns one datagram — whole, in any order, or never; everything else is your problem.