Một vòng xoay tải dữ liệu (loading spinner) chẳng cho bạn biết điều gì cả. Khi một tác vụ AI kéo dài hàng phút—hoặc quay lại hàng đợi để thử lại lần thứ ba—bạn cần phải thấy được trạng thái của nó. Server-Sent Events (SSE) mang lại khả năng hiển thị đó mà không cần quá trình bắt tay (handshake) phức tạp của WebSockets hay sự điều phối (choreography) của long polling. Máy chủ giữ một phản hồi HTTP duy nhất luôn mở và đẩy các bản cập nhật văn bản thuần túy khi có thay đổi. Client sẽ đọc chúng ngay khi chúng đến.
Nếu kết nối bị ngắt, có lẽ bạn không muốn phải bắt đầu lại từ đầu. Một luồng SSE được xây dựng tốt sẽ ghi nhớ vị trí bạn đang dừng lại. Chỉ với Node.js 20 và thư viện tiêu chuẩn, bạn có thể thiết lập điều này. Không cần đến các gói thư viện bên ngoài.
Định dạng truyền tải (wire format) trông như thế nào
Một tin nhắn SSE là văn bản đơn giản. Máy chủ sẽ ghi ba thứ: một tên sự kiện (event name) tùy chọn, một trường data bắt buộc, và một trường id đóng vai trò là điểm lưu (save point) của bạn. Mỗi bản ghi kết thúc bằng hai ký tự xuống dòng—một dòng trống đánh dấu ranh giới.
Một luồng hoạt động ổn định có thể trông như thế này trên đường truyền:
id: 14
event: status
data: {"phase":"testing","progress":43}
id: 15
event: status
data: {"phase":"retrying","attempt":2}
Client EventSource của trình duyệt sẽ tự động đọc các dòng này. Nó kích hoạt một sự kiện cho mỗi khối và lưu trữ id mới nhất trong bộ nhớ nội bộ. Nếu kết nối TCP bị lỗi, client sẽ chờ, kết nối lại và gửi định danh đã lưu về máy chủ dưới dạng header Last-Event-ID. Header đó chính là lý do cốt lõi giúp mô hình này hoạt động. Nếu không có nó, bạn sẽ không có một con trỏ (cursor) bền vững.
Thiết lập máy chủ trong Node.js
Module http tích hợp sẵn của Node có thể xử lý trực tiếp việc này. Khi có một yêu cầu đến, hãy thiết lập các header chính xác để client biết đây là một luồng (stream) chứ không phải một trang web:
Content-Type: text/event-stream
Cache-Control: no-cache
Connection: keep-alive
Loại bỏ việc đệm (buffering). Các proxy và framework đôi khi gom nhóm các phản hồi, điều này làm mất đi cảm giác thời gian thực, vì vậy hãy flush (đẩy dữ liệu) sau mỗi chunk.
Gửi ID trước, sau đó là loại sự kiện, rồi đến dữ liệu payload, và cuối cùng là dòng trống kết thúc. Thứ tự chỉ quan trọng ở chỗ ID phải đến trước dòng trống để client có thể ghi nhận nó. Nếu bạn đang sử dụng response.write() gốc, đầu ra sẽ chính xác là:
response.write(`id: ${cursor}\n`);
response.write(`event: ${eventName}\n`);
response.write(`data: ${JSON.stringify(payload)}\n\n`);
Ký tự \n\n ở cuối không phải để trang trí. Các bộ phân tích SSE coi đó là ký tự kết thúc bản ghi. Nếu thiếu nó, client sẽ bị treo trong khi chờ đợi thêm dữ liệu.
Con trỏ là tất cả
Một kết nối HTTP mới không đảm bảo một trạng thái mới. Khi client kết nối lại, header Last-Event-ID sẽ cho bạn biết tin nhắn cuối cùng mà họ đã nhận được. Nhiệm vụ của bạn là tiếp tục từ tin nhắn tiếp theo, chứ không phải từ đầu.
Điều này có nghĩa là bạn phải duy trì một nhật ký (log) hoặc nhật ký sự kiện (journal) có thứ tự ở phía máy chủ. Một mảng trong bộ nhớ (in-memory array) có thể dùng cho bản demo. Trong môi trường production, bạn cần thứ gì đó bền vững hơn—như ghi thêm vào log của cơ sở dữ liệu, một Redis stream, hoặc một write-ahead journal—vì việc khởi động lại máy chủ không nên xóa sạch lịch sử và buộc mọi client phải bắt đầu lại từ con số không.
Hãy lập chỉ mục (index) các sự kiện của bạn bằng một số nguyên tăng dần (monotonically increasing integer) hoặc một ULID. Khi có yêu cầu kết nối lại, hãy truy vấn các sự kiện có id > lastEventId và phát lại chúng theo thứ tự. Nếu bạn có hàng trăm tin nhắn bị tồn đọng, hãy chèn một khoảng trễ nhân tạo nhỏ hoặc gom nhóm chúng, nhưng hãy gửi theo thứ tự từ cũ nhất đến mới nhất để client có thể tái tạo lại trạng thái theo trình tự thời gian.
Hãy lường trước các bản trùng lặp
Mạng lưới không phải lúc nào cũng đáng tin cậy. Máy chủ có thể gửi một sự kiện, mất xác nhận TCP (acknowledgment), và gửi lại sự kiện đó sau khi hết thời gian chờ (timeout). Hãy thiết kế theo cơ chế "at-least-once delivery" (giao hàng ít nhất một lần) ngay từ đầu.
Ở phía client, việc loại bỏ trùng lặp (deduplication) rất đơn giản. Hãy giữ một Map với khóa là ID của sự kiện. Khi một sự kiện mới đến, hãy kiểm tra trong map. Nếu ID đã tồn tại, hãy âm thầm loại bỏ bản trùng lặp đó. Vì máy chủ của bạn gán các ID mang tính xác định (deterministic IDs), điều này giúp các bản trùng lặp trở nên vô hại. Map không cần phải lớn lên mãi mãi. Khi bạn xác nhận một sự kiện đã được xử lý an toàn, hãy xóa các ID cũ hơn. Một cửa sổ trượt (sliding window) gồm vài trăm mục thường là đủ cho các client trình duyệt.
Khi con trỏ hết hạn
Cuối cùng, một client sẽ kết nối lại sau nhiều giờ hoặc nhiều ngày. Nếu bộ đệm lịch sử của bạn chỉ bao gồm một nghìn sự kiện gần nhất mà client lại bị trễ tới hai nghìn sự kiện, việc phát lại các khoảng trống là không thể.
Đừng truyền tải một phần lịch sử. Điều đó sẽ khiến client rơi vào trạng thái không nhất quán. Thay vào đó, hãy phát hiện con trỏ đã hết hạn và gửi một bản chụp toàn bộ (full snapshot) dưới dạng sự kiện tiếp theo. Bản snapshot này nên mang theo một con trỏ mới để neo client vào trạng thái hiện tại. Từ đó, các thay đổi trực tiếp (live deltas) sẽ tiếp tục như bình thường. Hãy tài liệu hóa ranh giới này một cách rõ ràng trong giao thức của bạn để mã client biết khi nào cần đặt lại (reset) mô hình cục bộ thay vì chỉ thêm vào (append).
Bảo vệ luồng dữ liệu
Các endpoint SSE mở là những mục tiêu hấp dẫn. Bất kỳ ai cũng có thể giữ một kết nối, và các yêu cầu phát lại (replay requests) có thể làm tăng tải đọc lên bộ lưu trữ của bạn.
Gate the endpoint with proper authorization. Because the browser EventSource does not support custom headers, pass the token in the query string or use cookies with strict SameSite policies. Validate the token before you allocate stream resources.
Set history limits and per-user quotas. Cap the number of stored events per task, and cap the number of concurrent connections per client. Log disconnects and replays so you can spot a rogue client hammering your cursor endpoint.
The pattern travels
This approach is not trapped inside HTTP. The same rules apply when you move to WebSockets, message queues, or agent-to-agent interfaces. The transport changes—you might use binary frames or topic subscriptions—but the underlying problem stays identical. You need a cursor, a durable log, at-least-once semantics, client deduplication, and a fallback to full snapshots when the cursor goes stale. Solve state convergence once, and you can ship it over TCP, WebSocket, or a broker like RabbitMQ without redesigning the core logic.
Keep it simple
Server-Sent Events work because they ride on ordinary HTTP. Proxies understand them. Load balancers can health-check them. Debugging is as easy as curl. But that simplicity disappears if you ignore the edge cases. Build the cursor. Expect replays. Deduplicate on the client. snapshot when history runs out. Do that, and your long-running AI tasks will report their progress honestly, even through spotty Wi-Fi, server restarts, and the occasional overnight browser sleep.
Source: Build a Reconnecting SSE Task Stream with Node.js
Join the discussion: GyaanSetu AI Community
