Network Programming · Part 3 — UDP & Datagrams: Fast and Connectionless
UDP for Node.js + TypeScript: connectionless datagrams with node:dgram, when UDP beats TCP, the 65507-byte limit, no backpressure, and beginner pitfalls — bilingual, with runnable examples.
This is Part 3 of a 10-part series on network programming with Node.js + TypeScript {Đây là Phần 3 của series 10 bài về lập trình mạng với Node.js + TypeScript}. In Part 2 we built a TCP server — reliable, ordered, connection-oriented {Ở Phần 2 ta xây server TCP — tin cậy, có thứ tự, theo hướng kết nối}. Now we meet its opposite: UDP — fast, connectionless, and brutally honest about what it does not guarantee {Giờ ta gặp đối lập: UDP — nhanh, không kết nối, và thẳng thắn về những gì nó không đảm bảo}.
UDP is how the internet does DNS lookups, real-time game state, VoIP, and metrics firehoses {UDP là cách internet làm tra DNS, state game real-time, VoIP, và luồng metrics}. If your app can tolerate a little loss in exchange for low latency, UDP is often the right tool {Nếu app chấp nhận mất một chút để đổi lấy độ trễ thấp, UDP thường là công cụ đúng}.
What is UDP? {UDP là gì?}
UDP (User Datagram Protocol) sits at the Transport layer — same level as TCP, different contract {UDP (User Datagram Protocol) nằm ở tầng Transport — cùng tầng với TCP, nhưng hợp đồng khác}.
| Property | UDP behavior |
|---|---|
| Connection | None — no handshake, no session state {Không kết nối — không bắt tay, không trạng thái phiên} |
| Delivery | Best-effort — packets may be lost {Cố gắng hết sức — gói có thể mất} |
| Ordering | Not guaranteed — may arrive out of order {Không đảm bảo — có thể sai thứ tự} |
| Data model | Message-oriented — one send() = one datagram, one recv = one whole message {Theo message — một send() = một datagram, một lần nhận = một message nguyên vẹn} |
| Flow control | None — no backpressure, no window sizing {Không có — không backpressure, không cửa sổ} |
Key idea {Ý chính}: TCP gives you a reliable byte stream; UDP gives you independent datagrams and leaves reliability to your application {TCP cho bạn luồng byte tin cậy; UDP cho bạn datagram độc lập và để việc tin cậy cho ứng dụng}.
That last row — message-oriented — is the feature beginners overlook {Dòng cuối — theo message — là điểm người mới hay bỏ qua}. With TCP, two write('HELLO') calls might arrive as one chunk or split across three reads (Part 8) {Với TCP, hai lần write('HELLO') có thể gộp thành một mảnh hoặc tách thành ba lần đọc}. With UDP, each datagram keeps its boundary — what you send is exactly what the receiver gets in one message event {Với UDP, mỗi datagram giữ ranh giới — bạn gửi gì thì receiver nhận đúng vậy trong một sự kiện message}.
Fire-and-forget: no handshake {Bắn xong quên: không bắt tay}
TCP spends one round-trip on a three-way handshake before any data flows (Part 2) {TCP tốn một vòng bắt tay ba bước trước khi dữ liệu chảy}. UDP skips that entirely {UDP bỏ qua hoàn toàn}.
The sender calls send() with a destination address every time; the receiver’s socket fires 'message' for each arriving datagram {Sender gọi send() kèm địa chỉ đích mỗi lần; socket receiver bắn 'message' cho mỗi datagram đến}. There is no connect() ceremony, no end(), no half-open state to debug {Không có nghi thức connect(), không end(), không trạng thái half-open để debug}.
When UDP wins {Khi nào UDP thắng}
Reach for UDP when speed and simplicity matter more than every byte arriving in order {Chọn UDP khi tốc độ và đơn giản quan trọng hơn mọi byte đến đúng thứ tự}:
- DNS — tiny question/answer, retry at the app layer if needed {câu hỏi/trả lời nhỏ, retry ở tầng app nếu cần}.
- Real-time games — latest player position beats a stale but “confirmed” one {vị trí player mới nhất quan trọng hơn bản “xác nhận” nhưng cũ}.
- VoIP / live video — drop a frame, keep talking; TCP would stall the whole stream {bỏ một frame, tiếp tục nói; TCP sẽ làm cả luồng đứng}.
- Metrics & telemetry — losing 1 % of samples is acceptable; blocking is not {mất 1 % mẫu chấp nhận được; block thì không}.
- Discovery & broadcast — “who is on this LAN?” probes that need no persistent connection {thăm dò “ai trên LAN này?” không cần kết nối bền}.
If you need guaranteed delivery, ordering, or congestion control, stay on TCP (or add your own reliability layer on top of UDP, like QUIC does) {Nếu cần giao hàng đảm bảo, thứ tự, hoặc kiểm soát tắc nghẽn, ở lại TCP (hoặc tự thêm lớp tin cậy trên UDP, như QUIC)}.
A minimal UDP server {Server UDP tối giản}
Node exposes UDP through the built-in node:dgram module {Node expose UDP qua module node:dgram}. A server binds to a port and listens for datagrams {Server bind vào port và lắng nghe datagram}:
import { createSocket } from 'node:dgram';
const PORT = 41234;
const server = createSocket('udp4');
server.on('message', (msg, rinfo) => {
console.log(`got ${msg.length} bytes from ${rinfo.address}:${rinfo.port}`);
console.log('payload:', msg.toString('utf8'));
});
server.on('error', (err) => {
console.error('server error:', err);
server.close();
});
server.bind(PORT, () => {
console.log(`UDP server listening on ${PORT}`);
});
Key API points {Điểm API quan trọng}:
createSocket('udp4')— IPv4 UDP socket; use'udp6'for IPv6 {socket UDP IPv4; dùng'udp6'cho IPv6}.server.bind(port)— claim the port, likelisten()in TCP {chiếm port, giốnglisten()trong TCP}.'message'handler receives(msg: Buffer, rinfo: RemoteInfo)—msgis one complete datagram;rinfohasaddress,port,size{handler nhận(msg, rinfo)—msglà một datagram hoàn chỉnh;rinfocóaddress,port,size}.
A minimal UDP client {Client UDP tối giản}
The client does not “connect” — it just sends datagrams to an address {Client không “kết nối” — chỉ gửi datagram tới một địa chỉ}:
import { createSocket } from 'node:dgram';
const PORT = 41234;
const HOST = '127.0.0.1';
const client = createSocket('udp4');
const message = Buffer.from('ping');
client.send(message, PORT, HOST, (err) => {
if (err) {
console.error('send failed:', err);
client.close();
return;
}
console.log(`sent "${message.toString()}" to ${HOST}:${PORT}`);
client.close();
});
send(buffer, port, host, callback) ships one datagram {send(buffer, port, host, callback) gửi một datagram}. You must pass port and host on every send — there is no implicit peer unless you call socket.connect() (optional convenience, still connectionless at the protocol level) {Bạn phải truyền port và host mỗi lần gửi — không có peer ngầm trừ khi gọi socket.connect() (tiện ích tùy chọn, vẫn connectionless ở tầng giao thức)}.
Run server and client in two terminals {Chạy server và client ở hai terminal}:
npx tsx udp-server.ts # terminal 1
npx tsx udp-client.ts # terminal 2 → server prints the payload
Or poke the server with netcat in UDP mode {Hoặc chọc server bằng netcat chế độ UDP}:
echo -n "hello udp" | nc -u localhost 41234
Echo server: reply to whoever sent {Echo server: trả lời người gửi}
A practical pattern — read a datagram, send a response back to rinfo {Một pattern thực tế — đọc datagram, gửi phản hồi về rinfo}:
import { createSocket } from 'node:dgram';
const PORT = 41234;
const server = createSocket('udp4');
server.on('message', (msg, rinfo) => {
const text = msg.toString('utf8').trim();
const reply = Buffer.from(`echo: ${text}\n`);
// rinfo tells us exactly where to send the reply — no "connection" remembered.
server.send(reply, rinfo.port, rinfo.address, (err) => {
if (err) console.error('reply failed:', err);
});
});
server.bind(PORT, () => {
console.log(`echo server on udp/${PORT}`);
});
Matching client that waits for the echo {Client tương ứng chờ echo}:
import { createSocket } from 'node:dgram';
const PORT = 41234;
const client = createSocket('udp4');
client.on('message', (msg) => {
console.log('received:', msg.toString('utf8'));
client.close();
});
client.send(Buffer.from('hello'), PORT, '127.0.0.1');
Notice: the client also uses 'message' — UDP sockets are symmetric; either side can send and receive {Chú ý: client cũng dùng 'message' — socket UDP đối xứng; hai bên đều gửi và nhận được}.
Datagram size: the ~65 507-byte ceiling {Kích thước datagram: trần ~65 507 byte}
Theoretical max UDP payload ≈ 65 507 bytes (65 535-byte IP packet minus 8-byte UDP header minus 20-byte IPv4 header) {Payload UDP tối đa lý thuyết ≈ 65 507 byte}. In practice, keep datagrams much smaller — ideally under 1 200 bytes to avoid IP fragmentation {Thực tế, giữ datagram nhỏ hơn nhiều — lý tưởng dưới 1 200 byte để tránh phân mảnh IP}.
When a datagram exceeds the path MTU (often 1 500 bytes on Ethernet), the kernel splits it into fragments {Khi datagram vượt MTU đường truyền (thường 1 500 byte trên Ethernet), kernel chia thành mảnh}. Lose one fragment and the entire datagram is dropped — UDP has no retransmit {Mất một mảnh thì cả datagram bị hủy — UDP không retransmit}. Small, unfragmented packets survive better {Gói nhỏ, không phân mảnh sống sót tốt hơn}.
import { createSocket } from 'node:dgram';
const socket = createSocket('udp4');
// ❌ risky on real networks — may fragment and silently fail
const huge = Buffer.alloc(60_000, 0x41);
socket.send(huge, 41234, '127.0.0.1', (err) => {
if (err) console.error(err);
else console.log('sent 60 KB — works on loopback, risky on WAN');
socket.close();
});
Rule of thumb {Quy tắc ngón tay cái}: design messages under 512–1 200 bytes; if you need bigger payloads, use TCP or chunk with an app-level protocol {thiết kế message dưới 512–1 200 byte; nếu cần payload lớn hơn, dùng TCP hoặc chia chunk bằng giao thức app}.
No backpressure, no flow control {Không backpressure, không flow control}
TCP slows the sender when the receiver’s buffer fills — backpressure via the 'drain' event (Part 2) {TCP làm chậm sender khi buffer receiver đầy — backpressure qua sự kiện 'drain'}. UDP has nothing like that {UDP không có gì tương tự}.
If you blast datagrams faster than the receiver processes them, packets are dropped at the OS socket buffer with no notification to the sender {Nếu bắn datagram nhanh hơn receiver xử lý, gói bị drop ở buffer socket OS mà sender không được báo}. You must rate-limit yourself or build an application-level ACK/retry scheme {Bạn phải tự giới hạn tốc độ hoặc xây ACK/retry ở tầng app}.
import { createSocket } from 'node:dgram';
const socket = createSocket('udp4');
let sent = 0;
function blast() {
for (let i = 0; i < 100_000; i++) {
// No 'drain' event — send() queues until the kernel buffer overflows, then drops.
socket.send(Buffer.from(`pkt-${i}`), 41234, '127.0.0.1');
sent++;
}
console.log(`fired ${sent} datagrams — many may never arrive`);
socket.close();
}
blast();
This is a feature for low-latency workloads and a footgun for bulk transfer {Đây là ưu điểm cho workload độ trễ thấp và cạm bẫy cho truyền bulk}.
Broadcast & multicast (a mention) {Broadcast & multicast (nhắc qua)}
UDP can target more than one host {UDP có thể nhắm nhiều host}:
- Broadcast — send to
255.255.255.255or a subnet broadcast address; setsocket.setBroadcast(true)first {gửi tới255.255.255.255hoặc địa chỉ broadcast subnet; bậtsocket.setBroadcast(true)trước}. - Multicast — one send, many subscribers on a group address (e.g.
224.0.0.1); requiresaddMembership()and OS support {một lần gửi, nhiều subscriber trên địa chỉ nhóm; cầnaddMembership()và hỗ trợ OS}.
import { createSocket } from 'node:dgram';
const socket = createSocket('udp4');
socket.on('listening', () => {
socket.setBroadcast(true);
const msg = Buffer.from('discover-me');
socket.send(msg, 41234, '255.255.255.255');
console.log('broadcast probe sent');
});
socket.bind(41235);
Discovery protocols (mDNS, DHCP, game LAN lobbies) lean on these patterns — we will touch them again in later parts {Giao thức discovery (mDNS, DHCP, lobby game LAN) dựa vào các pattern này — ta sẽ đụng lại ở các phần sau}.
TCP vs UDP: side-by-side {TCP vs UDP: so sánh}
| TCP | UDP | |
|---|---|---|
| Connection | Yes — 3-way handshake | No — fire-and-forget |
| Reliability | Retransmits lost data | Best-effort; may lose packets |
| Ordering | In-order byte stream | No order guarantee |
| Message boundaries | Not preserved (byte stream) | Preserved (datagram) |
| Flow control | Yes (window, backpressure) | No |
| Congestion control | Yes (built into TCP) | No |
| Header overhead | 20+ bytes + options | 8 bytes |
| Latency | Higher (handshake + retransmit) | Lower (no setup) |
| Typical uses | HTTP, APIs, file transfer | DNS, games, VoIP, metrics |
| Node module | node:net | node:dgram |
Pick TCP when correctness is non-negotiable; pick UDP when freshness and speed beat perfect delivery {Chọn TCP khi đúng không thể thỏa hiệp; chọn UDP khi mới và nhanh quan trọng hơn giao hàng hoàn hảo}.
Mistakes beginners make {Lỗi người mới hay mắc}
- ❌ Expecting reliability or ordering from UDP — if packet 3 arrives before packet 2, that is normal; build sequence numbers and retries yourself if you need them {Kỳ vọng tin cậy hoặc thứ tự từ UDP — packet 3 đến trước packet 2 là bình thường; tự thêm số thứ tự và retry nếu cần}.
- ❌ Sending huge datagrams (tens of KB) and wondering why they vanish on Wi-Fi — fragmentation kills them silently {Gửi datagram khổng lồ rồi thắc mắc vì sao biến mất trên Wi-Fi — phân mảnh giết chúng âm thầm}.
- ❌ Forgetting there is no connection — you must pass
port+hoston everysend(), and userinfoto reply to the right peer {Quên không có kết nối — phải truyềnport+hostmỗi lầnsend(), và dùngrinfođể trả lời đúng peer}. - ❌ Assuming localhost behavior equals the real network — loopback rarely drops packets; production UDP loss and reordering only show up under load or on lossy links {Tưởng localhost giống mạng thật — loopback hiếm khi mất gói; mất và sai thứ tự UDP chỉ lộ khi có tải hoặc đường truyền kém}.
Exercises {Bài tập}
Try each before opening the solution {Thử từng bài trước khi mở lời giải}.
- Build the echo server and client above; send three messages rapidly and confirm each reply is a separate
messageevent with the correct prefix {Xây echo server và client; gửi ba message liên tiếp và xác nhận mỗi reply là sự kiệnmessageriêng với prefix đúng}. - Send a 20 000-byte datagram to your echo server on
localhostand then describe why you should not do this on a real LAN {Gửi datagram 20 000 byte tới echo server trênlocalhostrồi giải thích vì sao không nên làm vậy trên LAN thật}. - Modify the echo server to drop every other datagram (simulate loss) and write a client that sends numbered messages — observe gaps in the echoed sequence {Sửa echo server để drop một nửa datagram (giả lập mất); viết client gửi message đánh số — quan sát khoảng trống trong chuỗi echo}.
Solution {Lời giải}
// echo-server-lossy.ts — drops even-numbered sequence ids
import { createSocket } from 'node:dgram';
const PORT = 41234;
let count = 0;
const server = createSocket('udp4');
server.on('message', (msg, rinfo) => {
count++;
if (count % 2 === 0) return; // simulate 50% packet loss
const reply = Buffer.from(`echo: ${msg.toString('utf8')}`);
server.send(reply, rinfo.port, rinfo.address);
});
server.bind(PORT, () => console.log('lossy echo on', PORT));// seq-client.ts — sends 10 numbered datagrams, logs what comes back
import { createSocket } from 'node:dgram';
const client = createSocket('udp4');
const received: number[] = [];
client.on('message', (msg) => {
const match = msg.toString('utf8').match(/echo: msg-(\d+)/);
if (match) received.push(Number(match[1]));
});
for (let i = 0; i < 10; i++) {
client.send(Buffer.from(`msg-${i}`), 41234, '127.0.0.1');
}
setTimeout(() => {
const expected = Array.from({ length: 10 }, (_, i) => i);
const missing = expected.filter((n) => !received.includes(n));
console.log('received:', received.sort((a, b) => a - b));
console.log('missing (simulated loss):', missing);
client.close();
}, 500);Exercise 1: three separate send() calls produce three independent 'message' events — boundaries are preserved, unlike TCP {Bài 1: ba lần send() tạo ba sự kiện 'message' độc lập — ranh giới được giữ, khác TCP}.
Exercise 2: a 20 000-byte datagram works on loopback but exceeds typical MTU (1 500); the kernel fragments it and any lost fragment drops the whole datagram on a real network {Bài 2: datagram 20 000 byte chạy trên loopback nhưng vượt MTU thường; kernel phân mảnh và mất một mảnh là mất cả datagram trên mạng thật}.
Exercise 3: with the lossy server, roughly half the numbered replies never arrive — proof that ordering and completeness are your responsibility on UDP {Bài 3: với server lossy, khoảng nửa reply đánh số không đến — chứng minh thứ tự và đầy đủ là trách nhiệm của bạn trên UDP}.
Takeaway {Điều cốt lõi}
UDP is connectionless, message-oriented, and honest — it preserves datagram boundaries and adds almost no latency, but it will not fix loss, reordering, or congestion for you {UDP không kết nối, theo message, và thẳng thắn — giữ ranh giới datagram, thêm gần như không độ trễ, nhưng không sửa mất gói, sai thứ tự, hay tắc nghẽn giúp bạn}. In Node, node:dgram maps cleanly: bind to listen, send(buffer, port, host) to fire, 'message' to receive one whole datagram with rinfo for replies {Trong Node, node:dgram ánh xạ gọn: bind để nghe, send(buffer, port, host) để bắn, 'message' để nhận một datagram nguyên với rinfo để trả lời}. Keep payloads small, never assume localhost equals production, and reach for TCP when every byte must arrive {Giữ payload nhỏ, đừng coi localhost như production, và chọn TCP khi mọi byte phải đến}. Next up: how the internet turns names into addresses — DNS in Part 4 {Tiếp theo: internet biến tên thành địa chỉ thế nào — DNS ở Phần 4}.