Vấn đề thực tế
Team Product hoặc Marketing vừa gửi cho bạn một ticket: “Làm sao để khi khách vừa đặt hàng, số lượng đơn trên màn hình admin tự nhảy số ngay, không bắt nhân viên bấm F5?”.
Người dùng đã quen với việc Facebook hay Shopee nhảy thông báo liên tục. Họ nghĩ tính năng này chỉ cần vài phút là xong. Thực tế kỹ thuật lại không đơn giản như vậy.
Giả sử hệ thống có 10.000 nhân viên đang mở dashboard. Nếu trình duyệt cứ 2 giây gửi một request hỏi dữ liệu mới, server phải gánh tới 5.000 request/giây. Hơn 95% trong số đó trả về mảng rỗng vì không có đơn phát sinh. Hậu quả là database nghẽn hàng đợi, băng thông hao phí vô ích cho các HTTP header lặp đi lặp lại.
Nhiều bạn nghĩ ngay đến WebSocket. Nhưng WebSocket là con dao hai lưỡi.
Vì sao không phải lúc nào cũng nên dùng WebSocket?
Bản chất của HTTP truyền thống là cơ chế hỏi – đáp (Request – Response). Client hỏi thì server mới trả lời. Server không thể tự ý bắn dữ liệu xuống khi client không yêu cầu.
WebSocket giải quyết triệt để hạn chế này bằng cách tạo kênh truyền hai chiều toàn phần (full-duplex). Nó giống như một cuộc gọi điện thoại: hai bên thoải mái nói và nghe cùng lúc. Tuy nhiên, nếu bạn chỉ cần đẩy thông báo đơn hàng từ server về màn hình, việc dùng WebSocket quá lãng phí.
Duy trì một kết nối WebSocket đồng nghĩa server phải giữ stateful connection liên tục. Mỗi socket tốn từ 20KB đến 50KB RAM. Khi bạn scale ứng dụng lên nhiều container qua Kubernetes hoặc Docker Swarm, bạn buộc phải cấu hình sticky session trên Nginx hoặc dựng thêm Redis Pub/Sub để đồng bộ tin nhắn giữa các node. Chi phí vận hành và rủi ro rớt kết nối tăng gấp nhiều lần.
So sánh các giải pháp Real-time phổ biến
Trước khi chốt phương án, hãy nhìn lại 3 cách xử lý quen thuộc:
1. Short Polling (Hỏi liên tục)
Trình duyệt dùng setInterval gửi request HTTP GET đều đặn mỗi 1 đến 3 giây.
- Ưu điểm: Dễ code nhất, chỉ cần 3 dòng JavaScript là chạy được.
- Nhược điểm: Đốt tài nguyên khủng khiếp. Mỗi request kèm theo khoảng 500 byte header HTTP, dù payload trả về chỉ là mảng rỗng
[]. Server nhanh chóng chạm ngưỡng CPU.
2. Long Polling (Giữ kết nối)
Client gửi request lên, server không trả về ngay mà giữ kết nối (treo socket) cho đến khi có dữ liệu mới hoặc chạm timeout (thường là 30 giây). Nhận xong, client lại gửi ngay request mới.
- Ưu điểm: Giảm đáng kể lượng request rác so với Short Polling.
- Nhược điểm: Vẫn tốn công mở lại kết nối liên tục. Khi mạng chập chờn, việc quản lý reconnect và thứ tự gói tin rất dễ sót dữ liệu.
3. WebSocket (Kênh song công)
Nâng cấp kết nối HTTP qua giao thức ws:// hoặc wss:// để trao đổi tin nhắn hai chiều dạng frame nhị phân.
- Ưu điểm: Độ trễ cực thấp (dưới 10ms), phù hợp hoàn hảo cho game online, chat room hoặc bảng vẽ tương tác trực tiếp.
- Nhược điểm: Không dùng HTTP chuẩn, dễ bị firewall hay proxy công ty chặn. Bạn phải tự code logic heartbeat (ping/pong) và cơ chế reconnect khi đứt mạng.
Giải pháp tối ưu: Server-Sent Events (SSE)
Với nhu cầu chỉ cần luồng dữ liệu một chiều từ máy chủ xuống trình duyệt (feed thông báo, log server, tiến độ xuất báo cáo Excel, cập nhật giá cổ phiếu), Server-Sent Events (SSE) là lựa chọn thông minh nhất.
Lợi thế kỹ thuật của SSE
- Thuần HTTP: Hoạt động trên cổng 80/443 thông thường, đi qua mọi proxy, cân bằng tải và firewall mà không cần cấu hình đặc biệt.
- Tự động kết nối lại (Auto-reconnect): Trình duyệt tích hợp sẵn cơ chế thử lại nếu mạng bị ngắt. Bạn không phải viết hàm retry thủ công.
- Hỗ trợ chuẩn Event ID: Server có thể gắn kèm ID cho từng sự kiện. Nếu đứt mạng, trình duyệt gửi header
Last-Event-IDđể server bù đúng các tin chưa nhận. - Tận dụng HTTP/2: Trên HTTP/1.1, trình duyệt giới hạn 6 kết nối SSE trên mỗi domain. Nhưng khi bật HTTP/2, bạn có thể chạy hàng trăm luồng song song trên một kết nối TCP duy nhất nhờ tính năng multiplexing.
Bước 1: Xây dựng SSE Endpoint với Node.js & Express
Khởi tạo thư mục và cài đặt gói cần thiết:
mkdir sse-demo && cd sse-demo
npm init -y
npm install express cors
Tạo file server.js. Lưu ý quan trọng nhất là phải cấu hình đúng 3 HTTP response header:
const express = require('express');
const cors = require('cors');
const app = express();
app.use(cors());
app.get('/api/realtime-feed', (req, res) => {
// 1. Cấu hình header bắt buộc của chuẩn SSE
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
// Đẩy header xuống client ngay lập tức, tránh bị Express đệm lại
res.flushHeaders();
// Gửi sự kiện chào mừng khi kết nối thành công
res.write(`data: ${JSON.stringify({ message: 'Kết nối SSE thành công' })}\n\n`);
// Giả lập bắn thông báo đơn hàng mới mỗi 3 giây
const timerId = setInterval(() => {
const payload = {
orderId: 'DH-' + Math.floor(1000 + Math.random() * 9000),
amount: (Math.random() * 500).toFixed(2),
timestamp: new Date().toLocaleTimeString('vi-VN')
};
// Cấu trúc bắt buộc: bắt đầu bằng "data: " và kết thúc bằng hai dấu xuống dòng "\n\n"
res.write(`data: ${JSON.stringify(payload)}\n\n`);
}, 3000);
// 2. Thu hồi tài nguyên khi client đóng tab hoặc rời trang
req.on('close', () => {
clearInterval(timerId);
res.end();
console.log('Client ngắt kết nối. Đã giải phóng bộ nhớ.');
});
});
app.listen(5000, () => {
console.log('SSE Server đang lắng nghe tại port 5000');
});
Bước 2: Bắt sự kiện phía Client React
Bạn không cần cài socket.io-client hay bất kỳ package ngoài nào. Hãy dùng trực tiếp Web API EventSource có sẵn trong trình duyệt:
import React, { useState, useEffect } from 'react';
function DashboardOrders() {
const [orders, setOrders] = useState([]);
const [connectionStatus, setConnectionStatus] = useState('Đang kết nối...');
useEffect(() => {
// Khởi tạo EventSource trỏ tới server
const sse = new EventSource('http://localhost:5000/api/realtime-feed');
sse.onopen = () => {
setConnectionStatus('Đã kết nối trực tiếp');
};
// Lắng nghe gói tin từ server
sse.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
if (data.orderId) {
setOrders((prev) => [data, ...prev.slice(0, 9)]);
}
} catch (err) {
console.error('Lỗi parse JSON:', err);
}
};
// Trình duyệt sẽ tự động kích hoạt reconnect sau vài giây nếu xảy ra sự cố
sse.onerror = (err) => {
console.error('Lỗi đường truyền SSE:', err);
setConnectionStatus('Mất kết nối, trình duyệt đang tự thử lại...');
};
// Cleanup: Ngắt kết nối khi component bị unmount để tránh rò rỉ bộ nhớ
return () => {
sse.close();
};
}, []);
return (
<div style={{ padding: '24px', fontFamily: 'system-ui, sans-serif' }}>
<h2>Đơn hàng phát sinh theo thời gian thực</h2>
<p>Trạng thái: <strong>{connectionStatus}</strong></p>
<ul>
{orders.map((item, idx) => (
<li key={idx} style={{ marginBottom: '8px' }}>
<span style={{ color: '#666' }}>[{item.timestamp}]</span> Mã đơn: <strong>{item.orderId}</strong> — Tổng tiền: <span style={{ color: '#0d9488', fontWeight: 'bold' }}>${item.amount}</span>
</li>
))}
</ul>
</div>
);
}
export default DashboardOrders;
Bảng đối chiếu kỹ thuật: SSE vs. WebSocket
- Chiều truyền dữ liệu: SSE chỉ truyền 1 chiều (Server đến Client). WebSocket hỗ trợ 2 chiều (Client và Server bình đẳng).
- Giao thức nền tảng: SSE chạy trên HTTP tiêu chuẩn, tận dụng trọn vẹn HTTP/2 multiplexing. WebSocket chuyển hẳn sang giao thức TCP riêng (ws:// hoặc wss://).
- Tự động kết nối lại: SSE được trình duyệt hỗ trợ mặc định kèm ghi nhớ ID sự kiện. WebSocket buộc bạn phải tự code vòng lặp reconnect và lưu queue tin nhắn offline.
- Định dạng dữ liệu: SSE chuyên chở dữ liệu văn bản UTF-8 (thường dùng JSON). WebSocket hỗ trợ cả văn bản lẫn chuỗi nhị phân (Binary, ArrayBuffer).
Đừng phức tạp hóa kiến trúc khi bài toán của bạn chưa cần tới. Nếu ứng dụng không yêu cầu người dùng gửi ngược dữ liệu ở tốc độ mili-giây như game hay video call, Server-Sent Events chính là giải pháp tinh gọn, dễ bảo trì và tiết kiệm tài nguyên máy chủ nhất.

