UDP in Practice: Datagrams, MTU, App-Level Reliability, DNS, Games and QUIC
Key takeaways
Connectionless UDP: headers, checksums, ports, app-level reliability and ordering, and how HTTP/3 QUIC builds on UDP—realtime-focused guide.
Why UDP is back in focus
UDP (User Datagram Protocol) is a lightweight transport that sends datagrams without establishing a connection. TCP provides reliability, ordering, and congestion control; UDP only sends—loss, duplication, and reordering are handled by the application or upper layers (QUIC, RTP, …). That makes UDP common for DNS, realtime games, VoIP/video, and monitoring where latency or multicast matters. This article covers headers, ports, checksums, how retransmission and MTU work in practice, and how HTTP/3 / QUIC uses UDP.
The reason to choose UDP is rarely “it is faster”. It is that TCP’s guarantees are the wrong ones for the job. TCP delivers every byte in order, so one lost packet holds back everything behind it until the retransmission arrives (head-of-line blocking). For a file download that is exactly right. For a game or a voice call, a position update or an audio frame that arrives 200 ms late is worthless, and waiting for it delays the fresh data queued behind it. UDP lets the application decide, per message, what to do about loss: ignore it, send a newer state, or retransmit only what matters.
UDP since RFC 768
RFC 768 and the minimal design
RFC 768 (1980) standardizes UDP—IP multiplexing via ports and optional checksumming with minimal philosophy: the internet is assumed unstable, and realtime needs a thin layer.
Layer 4 without connection state
UDP is layer 4. IP reaches the host; UDP ports demux to sockets/processes. No connection state—each datagram is independent.
Datagrams, boundaries, and an 8-byte header
| Property | Description |
|---|---|
| Connectionless | No handshake (unless the app adds one). |
| Unreliable | Drops and reordering are not repaired by UDP. |
| Datagram boundaries | Each recv returns exactly one whole datagram; if the buffer is too small, the rest is discarded. |
| Multicast-friendly | Easier one-to-many patterns than TCP. |
| Low overhead | 8-byte header vs TCP. |
Message boundaries are the property people coming from TCP most often get wrong in the other direction. TCP is a byte stream, so one send of 100 bytes can arrive as two recv calls of 60 and 40, and applications must frame their messages. UDP never splits or merges datagrams: one sendto of 100 bytes arrives as one recvfrom of 100 bytes, or not at all. The trap is the receive buffer size. If you call recvfrom with a 512-byte buffer and the datagram is 1,400 bytes, you get the first 512 bytes and the remaining bytes are silently thrown away (Linux reports this with the MSG_TRUNC flag; Windows returns the WSAEMSGSIZE error). Size the receive buffer for the largest datagram you accept.
Header, checksum, ports, and QUIC
Header (IPv4)
| Field | Size | Role |
|---|---|---|
| Source port | 16 bits | Sender port (optional). |
| Destination port | 16 bits | Receiver port. |
| Length | 16 bits | UDP header + payload length. |
| Checksum | 16 bits | Optional (0) on IPv4; mandatory on IPv6 UDP. |
Checksum
IPv4 allows checksum disabled (0); IPv6 UDP requires checksums. Uses pseudo-header + UDP + payload. For stronger guarantees, add encryption and MAC at the app or TLS layer.
Ports
0–65535 demuxes processes on a host; well-known ports (e.g. DNS 53) are IANA-registered.
QUIC relationship (2026)
HTTP/3 uses QUIC over UDP. QUIC adds encryption, congestion control, streams, and connection migration—“simple UDP” vs “TCP-like reliability” in one modern stack.
flowchart TB
subgraph app [Applications]
H3[HTTP/3]
RTP[RTP / realtime media]
DNS[DNS client]
end
subgraph mid [Example stack]
QUIC[QUIC + TLS]
UDPR[UDP socket]
end
subgraph l3 [Network]
IP[IP]
end
H3 --> QUIC
QUIC --> UDPR
RTP --> UDPR
DNS --> UDPR
UDPR --> IP
UDP sockets in C++, Python, and Node.js
C++ (UDP client sketch)
// g++ -std=c++17 -O2 udp_client.cpp -o udp_client
#include <arpa/inet.h>
#include <cstring>
#include <iostream>
#include <string>
#include <sys/socket.h>
#include <unistd.h>
int main(int argc, char* argv[]) {
const char* host = argc > 1 ? argv[1] : "127.0.0.1";
const uint16_t port = argc > 2 ? static_cast<uint16_t>(std::stoi(argv[2])) : 5353;
int fd = socket(AF_INET, SOCK_DGRAM, 0);
if (fd < 0) { perror("socket"); return 1; }
sockaddr_in peer{};
peer.sin_family = AF_INET;
peer.sin_port = htons(port);
if (inet_pton(AF_INET, host, &peer.sin_addr) != 1) return 1;
const std::string msg = "ping";
if (sendto(fd, msg.data(), msg.size(), 0,
reinterpret_cast<sockaddr*>(&peer), sizeof(peer)) < 0) {
perror("sendto");
close(fd);
return 1;
}
char buf[2048];
socklen_t plen = sizeof(peer);
ssize_t n = recvfrom(fd, buf, sizeof(buf), 0,
reinterpret_cast<sockaddr*>(&peer), &plen);
if (n < 0) { perror("recvfrom"); close(fd); return 1; }
std::cout.write(buf, n);
std::cout << '\n';
close(fd);
return 0;
}
This sketch has the flaw that every first UDP client has: if the reply is lost, or no server is listening, recvfrom blocks forever. A request–response client needs a timeout, set with setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, ...) or by waiting with poll, after which recvfrom fails with EAGAIN/EWOULDBLOCK and the program can retry or give up. It also accepts a reply from anyone: recvfrom overwrites peer with whatever address sent the next datagram, so a real client should compare that address (and a request ID in the payload) with what it expects. The default port here, 5353, is the mDNS port, so on a machine running an mDNS responder you may see traffic you did not send; pick an unused port for experiments.
Python 3
#!/usr/bin/env python3
import socket
import sys
def main() -> None:
host = sys.argv[1] if len(sys.argv) > 1 else "127.0.0.1"
port = int(sys.argv[2]) if len(sys.argv) > 2 else 5353
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.settimeout(3.0)
try:
sock.sendto(b"ping", (host, port))
data, addr = sock.recvfrom(4096)
print(f"from {addr}: {data!r}")
except socket.timeout:
print("timeout: no response", file=sys.stderr)
raise SystemExit(1)
finally:
sock.close()
if __name__ == "__main__":
main()
Python adds the timeout that the C++ version lacks. One platform difference catches people here: when nothing listens on the target port, the target host answers with an ICMP “port unreachable” message. On Windows, the next recvfrom then raises ConnectionResetError: [WinError 10054] An existing connection was forcibly closed by the remote host, even though UDP has no connection; on Linux an unconnected socket usually ignores the ICMP message and you simply hit the timeout. A server that shares one socket among many clients on Windows must catch that error, or one departed client can make the receive loop crash.
Node.js (dgram)
// node udp_client.mjs
import dgram from "node:dgram";
const host = process.argv[2] ?? "127.0.0.1";
const port = Number(process.argv[3] ?? 5353);
const socket = dgram.createSocket("udp4");
const onMessage = (msg, rinfo) => {
console.log(`from ${rinfo.address}:${rinfo.port}: ${msg.toString()}`);
socket.close();
};
socket.on("message", onMessage);
socket.on("error", (err) => {
console.error(err.message);
socket.close();
process.exitCode = 1;
});
socket.send(Buffer.from("ping"), port, host, (err) => {
if (err) {
console.error(err.message);
process.exitCode = 1;
socket.close();
}
});
setTimeout(() => {
socket.close();
console.error("timeout");
process.exitCode = 1;
}, 3000).unref();
App-level reliability
Without TCP’s ACKs, request–response over UDP needs timeouts, retransmits with IDs, dedup, and backoff—or use QUIC/RTP that embed those concerns.
Each piece exists for a concrete failure. The request ID lets the client match a reply to its request when a delayed reply to an earlier attempt arrives after a retry. Deduplication on the server matters because a retry does not mean the first request was lost; the reply may have been lost, and a non-idempotent operation (“add 10 credits”) executed twice is a real bug. Exponential backoff with jitter keeps a thousand clients that lost packets at the same moment from retrying in lockstep and overloading the server again. DNS resolvers are a good model to copy: short timeouts, a small number of retries, a random 16-bit query ID, and a fallback to another server or to TCP.
Building all of this, plus congestion control, is where many custom UDP protocols go wrong. If what you need is “reliable, ordered, encrypted, but without TCP’s head-of-line blocking across independent messages”, that is QUIC, and using an existing QUIC library is far less work than growing your own protocol toward it.
Latency, throughput, and measuring loss
Latency
No handshake—you can send the first datagram immediately (firewalls/NAT aside). Important when time-to-first-packet dominates games and voice.
Throughput
UDP has no built-in windowed congestion control—you can flood, but you should not. Bulk transfer needs app congestion control or QUIC.
Overhead
8-byte UDP header—smaller than TCP until you add app/crypto headers.
Measuring
Loopback tests say little about real behaviour, because nothing is lost or delayed on loopback except when the receiver falls behind. On the internet, loss and jitter dominate games and voice more than raw Mbps. Test with realistic impairment, for example Linux tc netem (tc qdisc add dev eth0 root netem delay 80ms 20ms loss 2%), and measure what users notice: end-to-end latency percentiles, how often a frame or state update arrives too late, and recovery time after a burst of loss.
DNS, games, and realtime streaming
DNS
Most queries use UDP; large responses may fall back to TCP. Classic DNS limited UDP responses to 512 bytes; EDNS(0) lets the client advertise a larger buffer, and when a response still does not fit, the server sets the TC (truncated) bit and the client repeats the query over TCP on port 53. A firewall that allows only UDP 53 therefore breaks lookups of large records (many DNSSEC answers, long TXT records) in a way that looks random. Current practice recommends an EDNS buffer size around 1,232 bytes precisely to avoid IP fragmentation, which some networks drop.
Games
Latest state often beats retransmitting old state—UDP + app synchronization is common.
Realtime streaming
RTP typically runs on UDP; loss is handled with encoding, FEC, NACK, … WebRTC uses UDP—see the WebRTC guide.
MTU-sized packets, retransmission, and fairness
Packet size (MTU)
Design IP + UDP + payload under path MTU. Avoid IP fragmentation—loss costs a whole fragment chain.
In numbers: an Ethernet MTU of 1,500 bytes minus 20 bytes of IPv4 header and 8 bytes of UDP header leaves 1,472 bytes of payload, and IPv6’s 40-byte header leaves 1,452. Tunnels, VPNs and PPPoE shave off more, which is why protocols that must work everywhere stay well below: QUIC requires paths to carry 1,200-byte datagrams and uses that as its starting size. A fragmented datagram is lost if any fragment is lost, some firewalls drop fragments entirely, and the failure mode is nasty: small messages work, large ones vanish. If you see “only big packets disappear”, test with a smaller payload before looking anywhere else.
Retransmit and ordering
- Retransmit: measure RTT, handle jitter buffers, drop duplicates.
- Ordering: sequence numbers; decide whether late packets are dropped or buffered.
Fairness
Uncontrolled UDP floods can harm neighbors and trigger ISP throttling. Rate limits and congestion control are operational requirements, not niceties.
Loss, reordering, and NAT traversal
Loss
Common on Wi‑Fi, mobile, congested links—use FEC, lower bitrate, IFrame cadence, NACK/retransmit when delay allows.
Reordering
Multipath routing can reorder—use sequence numbers and reorder buffers, or stateless designs.
NAT and firewalls
UDP is stateless—NAT may expire mappings; keepalives, STUN/TURN (WebRTC), and QUIC keepalive address this.
The NAT timeout is the problem I would check first when a UDP application “stops receiving after a while”. A NAT or stateful firewall creates a mapping when the inside host sends a datagram and forgets it after a period of silence. For UDP that period is often much shorter than for TCP, sometimes as little as 30 seconds on consumer routers and carrier-grade NAT. Once the mapping is gone, replies from the server are dropped, and nothing tells either side. A client that sends a small keepalive every 15–25 seconds keeps the mapping alive; a server cannot fix it alone, because only outbound traffic from the inside refreshes the mapping.
UDP recap and when to pick it
Summary
- UDP favors low latency, simplicity, and multicast; reliability lives in the app or upper protocols.
- DNS, games, and realtime media are classic UDP domains; HTTP/3/QUIC adds modern reliability on top.
- MTU, timeouts, and retransmit policy are mandatory design topics.
When to choose UDP
Choose UDP when stale data is worse than lost data (game state, live audio and video), when a whole exchange fits in one small request and one small reply (DNS, NTP, service discovery), or when you need multicast or broadcast on a local network. Choose TCP, or QUIC, when every byte must arrive, when messages are large, or when you would otherwise end up implementing retransmission, ordering and congestion control yourself. And check the network before committing: some corporate networks block UDP except DNS, so UDP-based protocols such as QUIC keep a TCP fallback for exactly that reason.
Frequently Asked Questions (FAQ)
Q. Why do UDP packets go missing even between two processes on the same machine?
A. When the receiver does not read fast enough, the socket receive buffer fills and the kernel drops new datagrams without telling the sender, even over loopback. On Linux, netstat -su (or nstat) shows this as receive buffer errors. You can increase SO_RCVBUF (bounded by net.core.rmem_max) and keep the receive loop free of slow work, but the application still has to tolerate loss, because UDP gives no delivery guarantee on any path.
Related Articles
- TCP for Application Developers: Handshake, Sliding Window, Nagle and Keepalive
- Peer-to-Peer Video with WebRTC: Signaling, Data Channels and TURN