open atlas
↑ Back to track
Node.js, zero to senior NODE · 10 · 02

UDP and dgram: datagrams, message boundaries, and when to drop reliability

UDP via dgram sends independent datagrams with no connection, no ordering, and no delivery guarantee. Each send is one packet bounded by the MTU; you trade reliability for latency, and it wins for metrics, discovery, games, and DNS.

NODE Middle ◷ 16 min
Level
FoundationsJuniorMiddleSenior

A team moves their app metrics from synchronous HTTP POSTs to a StatsD agent over UDP, and p99 request latency drops by 30 ms overnight. The reason is brutal and simple: the HTTP metric calls were waiting for a TCP handshake, a reliable delivery, and a response — on the hot path of every request. The UDP send() does none of that. It hands one datagram to the kernel and returns; if the packet is lost, the metric is lost, and that is fine. They consciously traded a tiny fraction of metric accuracy for never blocking a user request on telemetry again.

Datagrams are messages, not a stream

TCP is a reliable, ordered byte stream; UDP is the opposite design point. The dgram module gives you a connectionless socket that sends and receives discrete datagrams — self-contained packets, each routed independently. There is no handshake, no connection state, no ordering, and no acknowledgement. A datagram either arrives whole or not at all.

This flips the framing problem from net on its head. In net you had to add message boundaries because TCP erases them; in dgram the boundaries are preserved for free — one send() produces exactly one 'message' event on the receiver with exactly those bytes. You never get a partial datagram and you never get two coalesced.

const dgram = require("node:dgram");

const server = dgram.createSocket("udp4");
server.on("message", (msg, rinfo) => {
  // msg is one whole datagram; rinfo = { address, port, size }
  console.log(`${rinfo.address}:${rinfo.port} -> ${msg.length} bytes`);
});
server.bind(8125);

const client = dgram.createSocket("udp4");
client.send(Buffer.from("requests:1|c"), 8125, "127.0.0.1");  // fire and forget

Note what is missing: no connect, no accept, no per-client socket object. The server binds a port and receives from anyone; rinfo tells you who sent each datagram. send() is fire-and-forget — its callback fires when the datagram was handed to the OS, not when it was delivered, because UDP has no concept of delivery confirmation.

No delivery, no order, no dedup — by design

Before you reach for UDP, ask yourself: does your protocol actually need every one of TCP’s guarantees, or are you paying for reliability you will never use? The table below makes the tradeoff concrete.

UDP gives you none of TCP’s guarantees, and that is the whole point.

PropertyTCP (net)UDP (dgram)
DeliveryGuaranteed (retransmit)Best-effort — may be dropped
OrderingIn-orderAny order
DuplicatesRemovedPossible
BoundariesErased (you frame)Preserved (1 send = 1 message)
ConnectionStateful handshakeConnectionless

If your application needs reliability or ordering on top of UDP, you implement it (sequence numbers, ACKs, retransmit) — which is exactly what QUIC and reliable-UDP game protocols do. The reason to start from UDP and add only what you need is latency: there is no handshake round trip, no head-of-line blocking (a lost TCP segment stalls everything behind it; a lost datagram affects only itself), and no connection state to manage at scale.

Message size and the MTU cliff

A datagram is bounded. The theoretical IPv4 UDP payload max is 65,507 bytes, but that is not the number that matters in production. The number that matters is the path MTU — typically ~1,500 bytes on Ethernet, often ~1,400 after tunnels/VPNs. A datagram larger than the path MTU must be IP-fragmented into multiple packets, and here is the cliff: if any one fragment is lost, the entire datagram is discarded — IP cannot reassemble a partial datagram. So a 4 KB datagram across a lossy path has a much higher effective loss rate than a 1 KB one, because it is three fragments that must all survive.

The practical rule: keep datagrams under the path MTU — a safe portable target is ~1,200 bytes of payload (the QUIC/DNS guidance). If you need to send more, fragment at the application layer into MTU-sized datagrams with your own sequencing, or use TCP. socket.send() does not split for you against your intent — it hands the whole buffer to the OS, which fragments at the IP layer with the fragility above.

Why this works

Why does one lost fragment kill the whole datagram? IP fragmentation splits a datagram across packets but only the first fragment carries the UDP header, and reassembly needs every fragment to rebuild the original. There is no per-fragment retransmit in UDP, so a single drop means the receiver holds incomplete pieces that time out and are thrown away. Staying under the MTU makes every datagram a single packet — atomic, no reassembly, no amplified loss.

Broadcast and multicast: one send, many receivers

UDP can address groups, which TCP fundamentally cannot. Broadcast sends one datagram to every host on the local subnet (socket.setBroadcast(true), send to the subnet broadcast address) — useful for “is anyone out there?” discovery on a LAN. Multicast is the disciplined version: receivers addMembership(groupAddr) to join a multicast group (224.0.0.0/4), and a single send() to the group address reaches exactly the joined members, with the network duplicating the packet only where paths diverge. setMulticastTTL bounds how many hops it travels. This is how service discovery (mDNS/Bonjour), some clustered-cache gossip, and IPTV-style fan-out work — one send, N receivers, no per-receiver connection.

Pick the best fit

You are choosing a transport for high-frequency application metrics on the request hot path.

Quiz

You send a single 4 KB UDP datagram across the public internet and it frequently fails to arrive intact, while 1 KB datagrams mostly succeed. Why?

Quiz

In dgram, when does socket.send(buf, port, host, cb) invoke cb?

Recall before you leave
  1. 01
    Why does a UDP datagram larger than the path MTU lose so much more often than a small one?
  2. 02
    When is UDP the right choice over TCP, and what do you give up?
Recap

UDP, exposed through Node’s dgram, is the connectionless counterpart to net’s reliable stream. You create a socket, bind a port, and receive whole datagrams as 'message' events with an rinfo describing the sender — no handshake, no per-client socket, no ordering, and no delivery guarantee. The framing problem inverts: where TCP erases message boundaries, UDP preserves them, so one send() is exactly one 'message', and send()’s callback fires on hand-off to the OS, not on delivery, because there is none to confirm. Datagrams are bounded by the path MTU, and a datagram larger than it is IP-fragmented into packets where a single lost fragment discards the whole thing — so keep payloads under ~1,200 bytes or fragment and sequence yourself. UDP’s unique power is group addressing: broadcast to a subnet and multicast to joined members let one send reach many receivers, which TCP cannot do. Choose UDP when latency and no head-of-line blocking beat reliability — metrics, discovery, games, voice, DNS — and accept that any reliability you need, you build on top. Now when you see a team reaching for TCP on a high-frequency metrics or discovery path, you know the question to ask: does that protocol actually need delivery guarantees, or is it just paying for them by default?

Practice

Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.

recallapplystretch0 of 5 done
Connected lessons

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.