Đang tải...

Ếch Trendy

Cùng chú ếch nhỏ khám phá thế giới Web đầy biến động. Cập nhật trending nhanh như cách ếch đớp mồi! ⌨️🌿


0

WebSocket và Socket.io: Xây dựng realtime features cho chat, live dashboard và game web

Ếch Trendy
Ếch Trendy

3 tháng trước · 9 phút đọc

Tại sao polling HTTP không đủ cho realtime

HTTP hoạt động theo mô hình request-response: client hỏi, server trả lời, kết nối đóng. Muốn cập nhật dữ liệu mới? Client phải hỏi lại. Cứ vậy lặp - gọi là polling.

Với chat app 1000 người dùng đang chờ tin nhắn, polling mỗi 1 giây nghĩa là server xử lý 1000 request/giây - gần như toàn bộ là "không có gì mới". Lãng phí tài nguyên theo kiểu khó chấp nhận hơn là bạn nghĩ.

345010 Polling vs WebSocket: polling tốn tài nguyên gấp nhiều lần dù dữ liệu không đổi

Long polling cải thiện một chút - server giữ kết nối mở đến khi có dữ liệu mới rồi mới trả về. Nhưng sau mỗi response, client phải tạo kết nối mới. Overhead vẫn cao, và xử lý timeout phức tạp.

WebSocket sinh ra để giải quyết đúng vấn đề này. Một kết nối TCP duy nhất, hai chiều, giữ mở suốt session. Server push data xuống bất cứ lúc nào mà không cần client hỏi trước. Đây là nền tảng của mọi ứng dụng realtime thực sự.


WebSocket thuần: hiểu từ nền tảng

Bắt đầu với WebSocket API của browser - không thư viện, không framework:

// Phía client (browser)
const socket = new WebSocket('ws://localhost:3000');

// Kết nối thành công
socket.addEventListener('open', () => {
  console.log('Đã kết nối!');
  socket.send(JSON.stringify({ type: 'join', room: 'general' }));
});

// Nhận message từ server
socket.addEventListener('message', (event) => {
  const data = JSON.parse(event.data);
  console.log('Tin nhắn mới:', data);
});

// Xử lý mất kết nối
socket.addEventListener('close', () => {
  console.log('Kết nối đóng, thử lại...');
});

Phía server với Node.js dùng thư viện ws:

const WebSocket = require('ws');
const wss = new WebSocket.Server({ port: 3000 });

// Lưu danh sách client đang kết nối
const clients = new Set();

wss.on('connection', (ws) => {
  clients.add(ws);
  console.log(`Client mới. Tổng: ${clients.size}`);

  ws.on('message', (rawData) => {
    const data = JSON.parse(rawData);
    
    // Broadcast đến tất cả client khác
    clients.forEach((client) => {
      if (client !== ws && client.readyState === WebSocket.OPEN) {
        client.send(JSON.stringify(data));
      }
    });
  });

  ws.on('close', () => {
    clients.delete(ws);
  });
});

345011 Một kết nối WebSocket duy nhất phục vụ cả gửi lẫn nhận

WebSocket thuần nhẹ và đơn giản, nhưng bạn phải tự xây mọi thứ: quản lý rooms, tái kết nối, heartbeat để phát hiện connection chết, xử lý lỗi parse JSON. Với prototype hay service nhỏ thì ổn. Với production app phức tạp thì bắt đầu đau đầu.


Socket.io: WebSocket với pin kèm theo

Socket.io không phải là WebSocket. Quan trọng phải hiểu điều này.

Socket.io là một thư viện abstraction layer xây trên WebSocket (và fallback về long polling nếu WS không khả dụng). Nó tự xử lý reconnection, namespace, rooms, broadcasting, acknowledgment - mọi thứ bạn sẽ phải tự viết khi dùng WebSocket thuần.

Dựng server với Socket.io:

const { createServer } = require('http');
const { Server } = require('socket.io');

const httpServer = createServer();
const io = new Server(httpServer, {
  cors: { origin: 'http://localhost:5173' }
});

io.on('connection', (socket) => {
  console.log(`User kết nối: ${socket.id}`);

  // Tham gia room
  socket.on('join-room', (roomId) => {
    socket.join(roomId);
    // Thông báo cho những người trong room
    socket.to(roomId).emit('user-joined', { id: socket.id });
  });

  // Gửi tin nhắn trong room
  socket.on('send-message', ({ roomId, message }) => {
    // Gửi đến TẤT CẢ trong room kể cả người gửi
    io.to(roomId).emit('new-message', {
      from: socket.id,
      message,
      timestamp: Date.now()
    });
  });

  socket.on('disconnect', () => {
    console.log(`User rời: ${socket.id}`);
  });
});

httpServer.listen(3000);

345012 Rooms trong Socket.io: mỗi room là một kênh broadcast riêng biệt

Phía client:

import { io } from 'socket.io-client';

const socket = io('http://localhost:3000');

// Vào room
socket.emit('join-room', 'room-123');

// Gửi tin
socket.emit('send-message', {
  roomId: 'room-123',
  message: 'Hello everyone!'
});

// Nhận tin
socket.on('new-message', (data) => {
  renderMessage(data);
});

So sánh nhanh để quyết định dùng cái nào:

Tiêu chí WebSocket thuần Socket.io
Bundle size ~0KB ~45KB client
Tự động reconnect ❌ Tự làm ✅ Có sẵn
Rooms/Namespaces ❌ Tự làm ✅ Có sẵn
Fallback transport ❌ Không ✅ Long polling
Debug tools ❌ Cơ bản ✅ Admin UI
Phù hợp Service đơn giản App phức tạp

Nếu bạn đang build chat app, collaborative tool hay game web - chọn Socket.io. Tiết kiệm ít nhất vài ngày tự xây infrastructure.


Ba use case thực tế: chat, live dashboard, game

Chat app

Chat là use case kinh điển. Mình sẽ chỉ phần hay bị sai: typing indicatorread receipts.

// Typing indicator - debounce phía client
let typingTimeout;
input.addEventListener('input', () => {
  socket.emit('typing', { roomId, userId });
  
  clearTimeout(typingTimeout);
  // Dừng gõ 2s thì gửi stop
  typingTimeout = setTimeout(() => {
    socket.emit('stop-typing', { roomId, userId });
  }, 2000);
});

// Phía server - forward event đến room
socket.on('typing', ({ roomId, userId }) => {
  socket.to(roomId).emit('user-typing', { userId });
});

Live dashboard

Dashboard realtime thường hiển thị metrics: đơn hàng mới, số người online, doanh thu theo giờ. Pattern hay dùng là server-push interval:

// Phía server - push metrics mỗi 5 giây
io.on('connection', (socket) => {
  const interval = setInterval(() => {
    const metrics = getLatestMetrics(); // Query DB
    socket.emit('metrics-update', metrics);
  }, 5000);
  
  // QUAN TRỌNG: dọn dẹp khi client rời
  socket.on('disconnect', () => {
    clearInterval(interval);
  });
});

Lỗi hay gặp: quên clearInterval khi disconnect. Sau vài giờ server của bạn sẽ có vài trăm interval chạy âm thầm cho những client đã rời từ lâu.

345013 Dọn dẹp interval khi disconnect - bước hay bị bỏ sót nhất

Game web đơn giản (multiplayer)

Game cần cập nhật vị trí player theo thời gian thực:

// Phía client - gửi vị trí liên tục (60fps)
function gameLoop() {
  socket.emit('player-move', {
    x: player.x,
    y: player.y,
    timestamp: Date.now()
  });
  requestAnimationFrame(gameLoop);
}

// Phía server - broadcast vị trí đến room
socket.on('player-move', (position) => {
  socket.to(roomId).emit('opponent-moved', {
    playerId: socket.id,
    ...position
  });
});

Game thực tế cần thêm server-side validation để chống gian lận, nhưng pattern cơ bản là vậy. Emit vị trí từ client → server broadcast đến room.


Reconnection và các lỗi triển khai phổ biến

Reconnection là thứ phân biệt prototype với production app.

Socket.io có auto-reconnect mặc định với exponential backoff. Nhưng bạn vẫn cần xử lý state recovery sau khi kết nối lại - người dùng cần biết họ đã miss gì trong thời gian offline.

// Client: theo dõi trạng thái kết nối
const socket = io({
  reconnectionDelay: 1000,     // Thử lại sau 1s
  reconnectionDelayMax: 5000,  // Tối đa 5s giữa các lần
  reconnectionAttempts: 10     // Thử tối đa 10 lần
});

socket.on('connect', () => {
  // Sau khi kết nối lại, sync missed messages
  socket.emit('sync', { lastMessageId: getLastSeenId() });
});

socket.on('disconnect', (reason) => {
  if (reason === 'io server disconnect') {
    // Server chủ động ngắt, phải reconnect thủ công
    socket.connect();
  }
  // Các reason khác: auto-reconnect tự xử lý
});

345014 Exponential backoff: tránh hammer server khi có sự cố

5 lỗi phổ biến nhất khi deploy realtime app:

  1. Quên CORS cho WebSocket - Socket.io cần cấu hình cors riêng, không inherit từ Express middleware thông thường.

  2. Load balancer không sticky sessions - Nếu bạn chạy nhiều server instance, client có thể reconnect vào instance khác và mất room state. Giải pháp: dùng Redis adapter cho Socket.io hoặc bật sticky sessions ở load balancer.

  3. Memory leak từ event listener - Trong React, nếu bạn attach listener trong useEffect mà không cleanup:

useEffect(() => {
  socket.on('new-message', handleMessage);
  
  // Bắt buộc cleanup để tránh duplicate listener
  return () => {
    socket.off('new-message', handleMessage);
  };
}, []);
  1. Gửi quá nhiều event - Game loop 60fps × 100 players = 6000 events/giây. Cân nhắc throttle hoặc chỉ gửi khi state thay đổi.

  2. Không validate dữ liệu server-side - Mọi socket.on trên server phải validate input. Client có thể gửi bất cứ thứ gì.

Khi scale lên nhiều server, Redis adapter là bắt buộc:

const { createAdapter } = require('@socket.io/redis-adapter');
const { createClient } = require('redis');

const pubClient = createClient({ url: 'redis://localhost:6379' });
const subClient = pubClient.duplicate();

await Promise.all([pubClient.connect(), subClient.connect()]);
io.adapter(createAdapter(pubClient, subClient));

Với Redis adapter, io.to(roomId).emit() hoạt động đúng dù client kết nối vào bất kỳ server instance nào.


Demo thực tế và tài nguyên để bắt đầu

Nếu muốn có playground để thử ngay, mình đã chuẩn bị một demo chat đơn giản bằng Socket.io:

Tải demo chat Socket.io (HTML)

Demo này bao gồm: tạo/join room bằng ID, gửi/nhận tin nhắn realtime, typing indicator, và hiển thị số người trong room. Đủ để bạn xem code structure trước khi bắt đầu dự án của mình.

345015 Bắt đầu nhỏ, thêm feature khi cần - đừng over-engineer từ đầu

Về học thêm: nền tảng JavaScript vững là điều kiện tiên quyết trước khi vào WebSocket. Khóa Lập Trình JavaScript Nâng Cao của F8 cover các concept như event loop, closure và async/await - tất cả đều liên quan trực tiếp khi bạn xử lý WebSocket events. Còn nếu muốn build backend cho realtime app, khóa Node & ExpressJS là điểm xuất phát tốt nhất.

Tài liệu chính thức:

Giờ bạn đã có đủ nền tảng. Bước tiếp:

  1. Dựng thử chat app cơ bản theo code phần trước
  2. Thêm rooms và typing indicator
  3. Deploy lên Railway hoặc Render, test với 2 browser khác nhau

Gặp lỗi CORS kỳ lạ hay client không nhận được event? Drop comment mình xem qua.