jvinhit//lab

Search posts

Type to search across journal entries.

navigate open esc close

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}.

PropertyUDP behavior
ConnectionNone — no handshake, no session state {Không kết nối — không bắt tay, không trạng thái phiên}
DeliveryBest-effort — packets may be lost {Cố gắng hết sức — gói có thể mất}
OrderingNot guaranteed — may arrive out of order {Không đảm bảo — có thể sai thứ tự}
Data modelMessage-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 controlNone — 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}.

Sender Receiver pkt 1 pkt 2 pkt 3 fire-and-forget · no handshake, no guarantee of order or delivery
UDP: independent datagrams fly with no setup — order and delivery are not guaranteed

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, like listen() in TCP {chiếm port, giống listen() trong TCP}.
  • 'message' handler receives (msg: Buffer, rinfo: RemoteInfo)msg is one complete datagram; rinfo has address, port, size {handler nhận (msg, rinfo)msgmột datagram hoàn chỉnh; rinfoaddress, 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.255 or a subnet broadcast address; set socket.setBroadcast(true) first {gửi tới 255.255.255.255 hoặc địa chỉ broadcast subnet; bật socket.setBroadcast(true) trước}.
  • Multicast — one send, many subscribers on a group address (e.g. 224.0.0.1); requires addMembership() and OS support {một lần gửi, nhiều subscriber trên địa chỉ nhóm; cần addMembership() 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}

TCPUDP
ConnectionYes — 3-way handshakeNo — fire-and-forget
ReliabilityRetransmits lost dataBest-effort; may lose packets
OrderingIn-order byte streamNo order guarantee
Message boundariesNot preserved (byte stream)Preserved (datagram)
Flow controlYes (window, backpressure)No
Congestion controlYes (built into TCP)No
Header overhead20+ bytes + options8 bytes
LatencyHigher (handshake + retransmit)Lower (no setup)
Typical usesHTTP, APIs, file transferDNS, games, VoIP, metrics
Node modulenode:netnode: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 + host on every send(), and use rinfo to reply to the right peer {Quên không có kết nối — phải truyền port + host mỗi lần send(), và dùng rinfo để 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}.

  1. Build the echo server and client above; send three messages rapidly and confirm each reply is a separate message event 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ện message riêng với prefix đúng}.
  2. Send a 20 000-byte datagram to your echo server on localhost and then describe why you should not do this on a real LAN {Gửi datagram 20 000 byte tới echo server trên localhost rồi giải thích vì sao không nên làm vậy trên LAN thật}.
  3. 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}.