Network Programming · Part 2 — TCP Sockets: A Reliable Byte Stream
Open real TCP sockets in Node.js: the three-way handshake, echo server + client with createServer/createConnection, socket events, graceful close, and common pitfalls — bilingual with exercises.
This is Part 2 of a 10-part series on network programming with Node.js + TypeScript {Đây là Phần 2 của series 10 bài về lập trình mạng với Node.js + TypeScript}. Part 1 gave you the layered mental model; now we open a real TCP socket and move bytes between two programs {Phần 1 đã cho bạn mô hình phân tầng; giờ ta mở socket TCP thật và truyền byte giữa hai chương trình}.
TCP is the workhorse of the internet — HTTP, WebSockets, and most databases ride on top of it {TCP là công cụ chủ lực của internet — HTTP, WebSocket, và hầu hết database chạy trên nó}. Understanding sockets directly makes every higher-level protocol easier to debug {Hiểu socket trực tiếp giúp debug mọi giao thức tầng cao dễ hơn}.
What TCP gives you {TCP mang lại gì}
TCP sits in the Transport layer (Part 1) and offers three guarantees {TCP nằm ở tầng Transport (Phần 1) và đưa ra ba đảm bảo}:
- Connection-oriented — both sides agree the link exists before data flows {Hướng kết nối — hai bên đồng ý liên kết tồn tại trước khi dữ liệu chảy}.
- Reliable — lost packets are retransmitted; duplicates are dropped {Tin cậy — packet mất được gửi lại; bản trùng bị loại}.
- Ordered byte stream — bytes arrive in the order sent, as one continuous stream {Luồng byte có thứ tự — byte đến đúng thứ tự gửi, thành một luồng liên tục}.
What TCP does not give you: message boundaries {TCP không cung cấp: ranh giới message}. If you send "HELLO" then "WORLD", the receiver might get "HEL" + "LOWORLD" in two 'data' events — same bytes, different chunking {Nếu bạn gửi "HELLO" rồi "WORLD", bên nhận có thể nhận "HEL" + "LOWORLD" trong hai sự kiện 'data' — cùng byte, khác cách cắt}. We fix that in Part 8 with framing {Ta sửa ở Phần 8 bằng framing}.
| Property | TCP | UDP (Part 3) |
|---|---|---|
| Connection | Yes — handshake first | No — fire-and-forget |
| Delivery | Guaranteed (retransmit) | Best-effort |
| Order | Preserved | Not guaranteed |
| Abstraction | Ordered byte stream | Independent datagrams |
| Typical use | HTTP, SSH, databases | DNS, gaming, video |
The three-way handshake {Bắt tay ba bước}
Before any application data moves, client and server perform a three-way handshake {Trước khi dữ liệu ứng dụng chảy, client và server thực hiện bắt tay ba bước}:
- Client → Server: SYN — “I want to connect; my initial sequence number is X” {Client → Server: SYN — “Tôi muốn kết nối; số thứ tự ban đầu của tôi là X”}.
- Server → Client: SYN + ACK — “OK, my sequence number is Y; I acknowledge X+1” {Server → Client: SYN + ACK — “OK, số thứ tự của tôi là Y; tôi xác nhận X+1”}.
- Client → Server: ACK — “I acknowledge Y+1; connection established” {Client → Server: ACK — “Tôi xác nhận Y+1; kết nối thiết lập”}.
You never write this by hand — the OS does it when you call connect() or accept() {Bạn không viết tay bước này — OS làm khi bạn gọi connect() hoặc accept()}. But knowing it explains connection delays and half-open states {Nhưng biết nó giải thích độ trễ kết nối và trạng thái half-open}.
After the handshake, both sides can write() and read() freely until someone closes the connection {Sau bắt tay, cả hai bên có thể write() và read() tự do cho đến khi ai đó đóng kết nối}.
Building a TCP echo server {Xây server TCP echo}
An echo server reads whatever you send and sends it back — the “Hello, World” of sockets {Server echo đọc bất cứ gì bạn gửi và gửi lại — “Hello, World” của socket}. Node’s node:net module exposes everything we need {Module node:net của Node cung cấp mọi thứ ta cần}.
import { createServer, type Socket } from 'node:net';
const PORT = 3000;
function handleConnection(socket: Socket): void {
console.log(
`connected: ${socket.remoteAddress}:${socket.remotePort}`,
);
socket.on('data', (chunk: Buffer) => {
const text = chunk.toString('utf8');
console.log(`received (${chunk.length} bytes):`, JSON.stringify(text));
socket.write(text); // echo back
});
socket.on('end', () => {
console.log('client finished sending (FIN received)');
});
socket.on('error', (err: Error) => {
console.error('socket error:', err.message);
});
socket.on('close', (hadError: boolean) => {
console.log(`connection closed (hadError=${hadError})`);
});
}
const server = createServer(handleConnection);
server.on('error', (err: Error) => {
console.error('server error:', err.message);
});
server.listen(PORT, () => {
console.log(`echo server listening on port ${PORT}`);
});
Save as echo-server.ts and run {Lưu thành echo-server.ts và chạy}:
npx tsx echo-server.ts # or: node --import tsx echo-server.ts
Test in another terminal {Thử ở terminal khác}:
nc localhost 3000
hello tcp
hello tcp # echoed back
# Ctrl+D to close (sends FIN)
Key events on the server socket {Sự kiện chính trên socket server}:
| Event | When it fires | What to do |
|---|---|---|
'connection' | New client connects (on server) | Hand off to handler |
'data' | Bytes arrive from peer | Process chunk; may fire multiple times per “message” |
'end' | Peer sent FIN (done writing) | Stop expecting more input |
'error' | Something broke (e.g. ECONNRESET) | Log; don’t let it go uncaught |
'close' | Socket fully shut down | Cleanup resources |
socket.remoteAddress and socket.remotePort tell you who connected — invaluable for logging and access control {socket.remoteAddress và socket.remotePort cho biết ai kết nối — vô giá cho logging và kiểm soát truy cập}.
Building a TCP client {Xây client TCP}
The client initiates the connection with createConnection() {Client khởi tạo kết nối bằng createConnection()}:
import { createConnection } from 'node:net';
const client = createConnection({ port: 3000, host: '127.0.0.1' }, () => {
console.log('connected to server');
client.write('hello from the client\n');
});
client.on('data', (chunk: Buffer) => {
console.log('server replied:', chunk.toString('utf8'));
client.end(); // send FIN — graceful close
});
client.on('close', () => {
console.log('connection closed');
});
client.on('error', (err: Error) => {
console.error('client error:', err.message);
});
Run the echo server first, then {Chạy server echo trước, rồi}:
npx tsx echo-client.ts
Expected output {Output mong đợi}:
connected to server
server replied: hello from the client
connection closed
The callback passed to createConnection fires on 'connect' — the handshake succeeded {Callback truyền vào createConnection chạy khi 'connect' — bắt tay thành công}. You can also use .on('connect', ...) if you prefer the event style {Bạn cũng có thể dùng .on('connect', ...) nếu thích kiểu event}.
The socket lifecycle {Vòng đời socket}
A TCP socket moves through a predictable lifecycle {Socket TCP đi qua vòng đời có thể dự đoán}:
[created] → connect/handshake → [established] → read/write data → [closing] → [closed]
On the server side, createServer listens; each incoming connection spawns a new Socket instance {Ở server, createServer lắng nghe; mỗi kết nối đến tạo một instance Socket mới}. One server process can handle thousands of concurrent sockets {Một process server có thể xử lý hàng nghìn socket đồng thời}.
'data' delivers Buffer chunks, not strings {'data' giao Buffer chunk, không phải string}. Always call .toString('utf8') (or another encoding) when you expect text {Luôn gọi .toString('utf8') (hoặc encoding khác) khi mong đợi text}. Binary protocols keep the Buffer as-is {Giao thức nhị phân giữ nguyên Buffer}.
Accumulate partial data when you need complete messages {Tích lũy dữ liệu một phần khi cần message hoàn chỉnh}:
let buffer = '';
socket.on('data', (chunk: Buffer) => {
buffer += chunk.toString('utf8');
// Example: process line-delimited messages
let newlineIndex: number;
while ((newlineIndex = buffer.indexOf('\n')) !== -1) {
const line = buffer.slice(0, newlineIndex);
buffer = buffer.slice(newlineIndex + 1);
console.log('complete line:', line);
}
});
This is a primitive form of framing — Part 8 goes deeper {Đây là dạng framing sơ khai — Phần 8 đi sâu hơn}.
Graceful close vs destroy {Đóng nhẹ nhàng vs destroy}
Two ways to end a connection — they are not the same {Hai cách kết thúc kết nối — chúng không giống nhau}:
| Method | What happens | When to use |
|---|---|---|
socket.end() | Sends FIN; drains write buffer; half-close (can still read) | Normal shutdown after finishing |
socket.destroy() | Sends RST; drops everything immediately | Timeout, bad state, force-kill |
Graceful close {Đóng nhẹ nhàng}: call socket.end() when done writing {gọi socket.end() khi viết xong}. The peer receives 'end' when your FIN arrives {Peer nhận 'end' khi FIN của bạn đến}. Both sides call end(), both get 'close' — clean shutdown {Cả hai gọi end(), cả hai nhận 'close' — tắt sạch}.
socket.write('goodbye\n');
socket.end(); // FIN sent — "I'm done writing"
Abrupt close {Đóng đột ngột}: socket.destroy() tears down immediately; the peer may see ECONNRESET {socket.destroy() phá ngay lập tức; peer có thể thấy ECONNRESET}. Use for error recovery, not normal flow {Dùng khi phục hồi lỗi, không phải luồng bình thường}.
setNoDelay and setKeepAlive {setNoDelay và setKeepAlive}
Two socket tuning knobs worth knowing {Hai nút tinh chỉnh socket đáng biết}:
socket.setNoDelay(true) — disables Nagle’s algorithm, which batches small writes to reduce packet count {socket.setNoDelay(true) — tắt thuật toán Nagle, gom các lần ghi nhỏ để giảm số packet}. For interactive protocols (games, chat) where latency matters, disable it {Với giao thức tương tác (game, chat) cần độ trễ thấp, hãy tắt}. For bulk transfer, leave the default (Nagle on) {Với truyền bulk, giữ mặc định (Nagle bật)}.
socket.setNoDelay(true);
socket.setKeepAlive(true) — sends periodic probe packets on idle connections so firewalls/NATs don’t drop them {socket.setKeepAlive(true) — gửi packet probe định kỳ trên kết nối idle để firewall/NAT không ngắt}. Useful for long-lived connections (database pools, WebSocket precursors) {Hữu ích cho kết nối sống lâu (pool database, tiền thân WebSocket)}. Optional initial delay in milliseconds {Độ trễ ban đầu tùy chọn tính bằng millisecond}:
socket.setKeepAlive(true, 30_000); // probe after 30s idle
Neither replaces application-level heartbeats for detecting dead peers {Không thay thế heartbeat tầng ứng dụng để phát hiện peer chết}.
Putting it together: run and test {Gắn lại: chạy và thử}
Terminal 1 — start the echo server {Terminal 1 — khởi động server echo}:
npx tsx echo-server.ts
Terminal 2 — raw client with netcat {Terminal 2 — client thô với netcat}:
nc localhost 3000
ping
ping
pong would be wrong — you get "ping" echoed
Terminal 3 — run the TypeScript client {Terminal 3 — chạy client TypeScript}:
npx tsx echo-client.ts
Watch the server log: you’ll see remoteAddress, byte counts, 'end', and 'close' for each session {Xem log server: bạn sẽ thấy remoteAddress, số byte, 'end', và 'close' cho mỗi phiên}.
Mistakes beginners make {Lỗi người mới hay mắc}
- ❌ Treating each
'data'event as one complete message — TCP is a byte stream; one write can split across many'data'events, or many writes can merge into one (Part 8) {Coi mỗi'data'là một message hoàn chỉnh — TCP là luồng byte; một lần write có thể tách qua nhiều'data', hoặc nhiều write gộp thành một (Phần 8)}. - ❌ Not handling
'error'on sockets — an uncaughtECONNRESETcrashes your Node process {Không xử lý'error'trên socket —ECONNRESETkhông bắt sẽ crash process Node}. - ❌ Forgetting backpressure exists —
socket.write()can returnfalsewhen the kernel buffer is full; you must pause and wait for'drain'(Part 8) {Quên backpressure tồn tại —socket.write()có thể trảfalsekhi buffer kernel đầy; phải tạm dừng và chờ'drain'(Phần 8)}. - ❌ Calling
destroy()on every disconnect instead ofend()— peers see resets and can’t flush pending data {Gọidestroy()mỗi lần ngắt thay vìend()— peer thấy reset và không flush dữ liệu đang chờ}. - ❌ Assuming
remoteAddressis always an IP string — it can beundefinedbefore connect, or'::ffff:127.0.0.1'for IPv4-mapped IPv6 {TưởngremoteAddressluôn là chuỗi IP — có thểundefinedtrước connect, hoặc'::ffff:127.0.0.1'cho IPv4-mapped IPv6}.
Exercises {Bài tập}
Try each before opening the solution {Thử từng bài trước khi mở lời giải}.
- Extend the echo server to prefix every reply with
[echo](e.g.[echo] hello) {Mở rộng server echo để thêm tiền tố[echo]trước mỗi reply}. - Write a client that sends three lines (
line1\n,line2\n,line3\n) without waiting for a reply between writes; log how many'data'events the server fires {Viết client gửi ba dòng mà không chờ reply giữa các lần write; log số sự kiện'data'server bắn ra}. - Connect with
nc, type a message, then killncwith Ctrl+C (RST) instead of Ctrl+D (FIN). Observe the server’s'error'/'close'output {Kết nối bằngnc, gõ message, rồi killncbằng Ctrl+C (RST) thay vì Ctrl+D (FIN). Quan sát output'error'/'close'của server}.
Solution {Lời giải}
// 1 — prefixed echo (change the write line in handleConnection)
socket.write(`[echo] ${text}`);
// 2 — fire three lines, count server data events
import { createConnection } from 'node:net';
const client = createConnection({ port: 3000, host: '127.0.0.1' }, () => {
client.write('line1\n');
client.write('line2\n');
client.write('line3\n');
client.end();
});
client.on('data', (chunk) => {
console.log('got chunk:', JSON.stringify(chunk.toString()));
});
// On the server, add a counter in 'data':
let dataEvents = 0;
socket.on('data', (chunk) => {
dataEvents += 1;
console.log(`data event #${dataEvents}, ${chunk.length} bytes`);
socket.write(`[echo] ${chunk.toString('utf8')}`);
});
// Typical result: 1 data event with all three lines merged, or 2–3 if timing differsExercise 3: Ctrl+C sends RST; the server logs something like socket error: read ECONNRESET and 'close' with hadError=true {Bài 3: Ctrl+C gửi RST; server log kiểu socket error: read ECONNRESET và 'close' với hadError=true}. Ctrl+D sends FIN gracefully — you get 'end' then 'close' with hadError=false {Ctrl+D gửi FIN nhẹ nhàng — bạn nhận 'end' rồi 'close' với hadError=false}.
Takeaway {Điều cốt lõi}
TCP gives you a reliable, ordered byte stream over a connection established by a three-way handshake {TCP cho bạn luồng byte tin cậy, có thứ tự trên kết nối thiết lập bằng bắt tay ba bước}. In Node.js, createServer and createConnection from node:net wrap that stream with 'data', 'end', 'error', and 'close' events {Trong Node.js, createServer và createConnection từ node:net bọc luồng đó bằng sự kiện 'data', 'end', 'error', và 'close'}. Treat sockets as streams of bytes, handle errors, close gracefully with end(), and remember that framing and backpressure are your job — topics we tackle in Part 8 {Coi socket là luồng byte, xử lý lỗi, đóng nhẹ bằng end(), và nhớ framing cùng backpressure là việc của bạn — ta sẽ làm ở Phần 8}. Next up: UDP — the fast, connectionless alternative in Part 3 {Tiếp theo: UDP — lựa chọn nhanh, không kết nối ở Phần 3}.