Đ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 & Socket.io: Xây Realtime Multiplayer Game Cho Dev Việt 2026

Ếch Trendy
Ếch Trendy

6 tháng trước · 13 phút đọc

WebSocket là gì và tại sao multiplayer game cần nó?

HTTP truyền thống hoạt động theo kiểu request-response: client hỏi, server trả lời, kết nối đóng. Mỗi lần muốn dữ liệu mới, client phải hỏi lại. Với game multiplayer, mô hình này thất bại hoàn toàn - bạn không thể để người dùng "hỏi lại" mỗi 100ms để biết đối thủ vừa di chuyển đến đâu.

WebSocket giải quyết bằng cách duy trì một kết nối hai chiều liên tục (persistent bidirectional connection) giữa client và server. Sau handshake ban đầu qua HTTP, giao thức chuyển sang WebSocket - từ đó server có thể chủ động push data xuống client bất cứ lúc nào mà không cần client hỏi trước.

342980 Server push thẳng xuống client - không cần polling mỗi giây

Kết quả thực tế? Latency giảm đáng kể so với polling. 17.81 ms (ws WebSocket) vs ~19 ms (others) at 1000 clients; socket.io 5.12 ms at 100 clients (Comparative Performance Benchmarking of WebSocket Libraries) Với chat app hoặc game realtime, đây là sự khác biệt giữa trải nghiệm mượt mà và lag khó chịu.

Socket.io là thư viện build trên WebSocket, thêm vào đó:

  • Automatic reconnection: mất mạng tự kết nối lại
  • Room/namespace: nhóm connection theo logic (phòng game, channel chat)
  • Fallback: nếu WebSocket bị block (proxy cứng), tự dùng long-polling
  • Event-based API: emit/on thay vì xử lý raw message

Năm 2026, Socket.io đạt hàng triệu lượt tải mỗi tuần Socket.io vẫn là lựa chọn phổ biến nhất cho realtime app trong hệ sinh thái Node.js, đặc biệt với các team Việt Nam build sản phẩm nhanh.


Setup project fullstack: Node.js server + React client

Mình sẽ build một demo hoàn chỉnh: multiplayer Tic-tac-toe (cờ ca rô đơn giản). Đủ để thấy cách Socket.io xử lý game state, room management và realtime sync.

Cấu trúc project

realtime-game/
├── server/          # Node.js + Socket.io
│   ├── index.js
│   └── package.json
└── client/          # React + Vite
    ├── src/
    │   ├── App.jsx
    │   └── socket.js
    └── package.json

Server: khởi tạo Socket.io với Express

// server/index.js
const express = require('express');
const { createServer } = require('http');
const { Server } = require('socket.io');

const app = express();
const httpServer = createServer(app);

// Cấu hình CORS cho React dev server
const io = new Server(httpServer, {
  cors: {
    origin: process.env.CLIENT_URL || 'http://localhost:5173',
    methods: ['GET', 'POST'],
  },
});

// Lưu state các phòng game trong memory
const rooms = {};

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

  // Xử lý tạo/tham gia phòng
  socket.on('join_room', (roomId) => {
    socket.join(roomId);

    if (!rooms[roomId]) {
      // Tạo phòng mới
      rooms[roomId] = {
        players: [socket.id],
        board: Array(9).fill(null),
        currentTurn: null,
        status: 'waiting',
      };
      socket.emit('room_status', { status: 'waiting', message: 'Đang chờ đối thủ...' });
    } else if (rooms[roomId].players.length < 2) {
      // Người thứ 2 vào phòng -> bắt đầu game
      rooms[roomId].players.push(socket.id);
      rooms[roomId].currentTurn = rooms[roomId].players[0];
      rooms[roomId].status = 'playing';

      // Thông báo cho cả phòng
      io.to(roomId).emit('game_start', {
        players: rooms[roomId].players,
        currentTurn: rooms[roomId].currentTurn,
      });
    } else {
      socket.emit('room_full', { message: 'Phòng đã đầy!' });
    }
  });

  // Xử lý nước đi
  socket.on('make_move', ({ roomId, index, symbol }) => {
    const room = rooms[roomId];
    if (!room || room.currentTurn !== socket.id) return;
    if (room.board[index] !== null) return; // Ô đã có quân

    // Cập nhật board
    room.board[index] = symbol;
    
    // Kiểm tra thắng thua
    const winner = checkWinner(room.board);
    if (winner) {
      io.to(roomId).emit('game_over', { winner: socket.id, board: room.board });
      delete rooms[roomId];
      return;
    }

    // Chuyển lượt
    const nextPlayer = room.players.find((p) => p !== socket.id);
    room.currentTurn = nextPlayer;

    io.to(roomId).emit('board_update', {
      board: room.board,
      currentTurn: nextPlayer,
    });
  });

  // Cleanup khi ngắt kết nối
  socket.on('disconnect', () => {
    Object.keys(rooms).forEach((roomId) => {
      const room = rooms[roomId];
      if (room && room.players.includes(socket.id)) {
        io.to(roomId).emit('player_left', { message: 'Đối thủ đã rời phòng' });
        delete rooms[roomId];
      }
    });
  });
});

// Hàm kiểm tra người thắng
function checkWinner(board) {
  const lines = [
    [0, 1, 2], [3, 4, 5], [6, 7, 8], // Hàng ngang
    [0, 3, 6], [1, 4, 7], [2, 5, 8], // Hàng dọc
    [0, 4, 8], [2, 4, 6],             // Chéo
  ];
  return lines.some(([a, b, c]) => board[a] && board[a] === board[b] && board[a] === board[c]);
}

httpServer.listen(3001, () => console.log('Server chạy tại port 3001'));

342981 Room management theo roomId - mỗi phòng là một namespace riêng biệt

Client: kết nối Socket.io từ React

// client/src/socket.js
import { io } from 'socket.io-client';

// Singleton - chỉ tạo 1 connection duy nhất
const socket = io(import.meta.env.VITE_SERVER_URL || 'http://localhost:3001', {
  autoConnect: false, // Kết nối thủ công khi cần
});

export default socket;
// client/src/App.jsx
import { useEffect, useState } from 'react';
import socket from './socket';

export default function App() {
  const [roomId, setRoomId] = useState('');
  const [gameState, setGameState] = useState(null);
  const [status, setStatus] = useState('idle');
  const [mySymbol, setMySymbol] = useState(null);

  useEffect(() => {
    socket.connect();

    socket.on('room_status', ({ message }) => {
      setStatus(message);
    });

    socket.on('game_start', ({ players, currentTurn }) => {
      // Xác định mình là X hay O
      const symbol = players[0] === socket.id ? 'X' : 'O';
      setMySymbol(symbol);
      setGameState({ board: Array(9).fill(null), currentTurn });
      setStatus(currentTurn === socket.id ? 'Lượt của bạn' : 'Lượt đối thủ');
    });

    socket.on('board_update', ({ board, currentTurn }) => {
      setGameState({ board, currentTurn });
      setStatus(currentTurn === socket.id ? 'Lượt của bạn' : 'Lượt đối thủ');
    });

    socket.on('game_over', ({ winner }) => {
      setStatus(winner === socket.id ? '🎉 Bạn thắng!' : '😢 Bạn thua!');
    });

    return () => socket.disconnect();
  }, []);

  const joinRoom = () => {
    if (roomId.trim()) socket.emit('join_room', roomId.trim());
  };

  const makeMove = (index) => {
    if (!gameState || gameState.currentTurn !== socket.id) return;
    socket.emit('make_move', { roomId, index, symbol: mySymbol });
  };

  return (
    <div style={{ padding: 24 }}>
      <h1>Cờ Ca Rô Realtime</h1>
      {!gameState && (
        <div>
          <input value={roomId} onChange={(e) => setRoomId(e.target.value)} placeholder="Nhập mã phòng..." />
          <button onClick={joinRoom}>Vào phòng</button>
        </div>
      )}
      <p>{status}</p>
      {gameState && (
        <div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 80px)', gap: 4 }}>
          {gameState.board.map((cell, i) => (
            <button key={i} onClick={() => makeMove(i)} style={{ height: 80, fontSize: 32 }}>
              {cell}
            </button>
          ))}
        </div>
      )}
    </div>
  );
}

Chạy server: node server/index.js. Mở 2 tab browser, nhập cùng một roomId - game bắt đầu ngay. Đây là nền tảng để build bất kỳ realtime feature nào.

Nếu bạn chưa quen với Node.js và Express, khóa Node & ExpressJS cover toàn bộ phần backend này từ đầu. Còn nếu muốn xây fullstack hoàn chỉnh, xem thêm Lập trình web fullstack với NodeJS và ReactJS.


Xử lý các pattern phổ biến: chat, live update, presence

Game là use case đặc thù. Trong thực tế, 80% project realtime bạn gặp sẽ là chat hoặc live update. Mình tóm tắt các pattern thường dùng.

Pattern 1: Chat app với typing indicator

// Server - xử lý typing indicator
socket.on('typing', ({ roomId, username }) => {
  // Broadcast cho những người KHÁC trong phòng (trừ người gửi)
  socket.to(roomId).emit('user_typing', { username });
});

socket.on('stop_typing', ({ roomId }) => {
  socket.to(roomId).emit('user_stop_typing');
});

socket.on('send_message', ({ roomId, message, username }) => {
  const payload = {
    id: Date.now(),
    username,
    message,
    timestamp: new Date().toISOString(),
  };
  // Gửi cho TẤT CẢ người trong phòng (kể cả người gửi)
  io.to(roomId).emit('new_message', payload);
});
// React client - debounce typing event để tránh spam
import { useCallback } from 'react';
import { debounce } from 'lodash';

const emitTyping = useCallback(
  debounce(() => {
    socket.emit('stop_typing', { roomId });
  }, 1000),
  [roomId]
);

const handleInput = (e) => {
  setMessage(e.target.value);
  socket.emit('typing', { roomId, username: myUsername });
  emitTyping(); // Gọi debounce - sau 1s không gõ thêm sẽ emit stop_typing
};

Điểm quan trọng: socket.to(room).emit() gửi cho tất cả trừ người gửi. io.to(room).emit() gửi cho tất cả kể cả người gửi. Nhầm chỗ này là bug khó debug.

342982 Phân biệt socket.to() vs io.to() - lỗi thường gặp nhất khi mới dùng Socket.io

Pattern 2: Presence system (ai đang online?)

// Server - theo dõi user online
const onlineUsers = new Map(); // socketId -> userData

io.on('connection', (socket) => {
  socket.on('user_join', (userData) => {
    onlineUsers.set(socket.id, userData);
    // Thông báo cho tất cả user hiện đang online
    io.emit('online_users', Array.from(onlineUsers.values()));
  });

  socket.on('disconnect', () => {
    onlineUsers.delete(socket.id);
    io.emit('online_users', Array.from(onlineUsers.values()));
  });
});

Pattern 3: Live dashboard / stock update

// Server - push update định kỳ cho subscribers
const subscribers = new Set();

io.on('connection', (socket) => {
  socket.on('subscribe_dashboard', () => {
    subscribers.add(socket.id);
  });

  socket.on('unsubscribe_dashboard', () => {
    subscribers.delete(socket.id);
  });
});

// Cứ mỗi 5 giây, đẩy data mới xuống subscribers
setInterval(async () => {
  if (subscribers.size === 0) return; // Không ai subscribe thì bỏ qua
  const data = await fetchLiveData(); // Lấy data từ DB/API
  subscribers.forEach((socketId) => {
    io.to(socketId).emit('dashboard_update', data);
  });
}, 5000);

Ba pattern này - chat, presence, live data - cover phần lớn phần lớn use case realtime trong các sản phẩm thực tế. Master 3 cái này là bạn xử lý được hầu hết yêu cầu.


Scalability: khi 1 server Node.js không đủ

Socket.io chạy tốt trên 1 server. Vấn đề xuất hiện khi bạn cần scale lên nhiều instance - ví dụ deploy 3 container Node.js sau load balancer. Lúc này, user A kết nối vào instance 1, user B kết nối vào instance 2. Khi A emit event, B không nhận được vì họ ở 2 server khác nhau.

Giải pháp chuẩn: Socket.io Redis Adapter.

342983 Redis làm message broker giữa các Node.js instance - đây là cách scale đúng

npm install @socket.io/redis-adapter ioredis
// server/index.js - thêm Redis adapter
const { createAdapter } = require('@socket.io/redis-adapter');
const { createClient } = require('ioredis');

const pubClient = createClient({ url: process.env.REDIS_URL });
const subClient = pubClient.duplicate();

Promise.all([pubClient.connect(), subClient.connect()]).then(() => {
  io.adapter(createAdapter(pubClient, subClient));
  httpServer.listen(3001);
  console.log('Socket.io với Redis adapter đã sẵn sàng');
});

Sau khi thêm Redis adapter, mọi event emit/broadcast sẽ được sync qua Redis Pub/Sub giữa tất cả instance. User A ở instance 1 emit - Redis forward sang instance 2 - user B nhận được bình thường.

Sticky sessions nếu không dùng Redis

Nếu Redis quá phức tạp cho giai đoạn đầu, cách đơn giản hơn là cấu hình sticky sessions trên load balancer: đảm bảo mỗi user luôn được route về đúng một server trong suốt session. Nginx hỗ trợ qua ip_hash, AWS ALB hỗ trợ qua session stickiness.

Trade-off: sticky sessions không scale tốt khi 1 server die - tất cả connection trên server đó bị mất. Redis adapter không có vấn đề này.

Giới hạn connection trên 1 server

Một Node.js instance có thể handle 20K concurrent connections (dev.to/alex_aslam) WebSocket connection đồng thời trên hardware thông thường. Với app Việt Nam giai đoạn đầu (vài nghìn CCU), 1-2 server là đủ. Khi scale lên, thêm Redis adapter + thêm instance là xong.

Rate limiting để tránh abuse

// Giới hạn mỗi socket chỉ emit tối đa 10 event/giây
const rateLimiter = new Map();

function isRateLimited(socketId) {
  const now = Date.now();
  const windowMs = 1000; // 1 giây
  const maxEvents = 10;

  if (!rateLimiter.has(socketId)) {
    rateLimiter.set(socketId, { count: 1, resetAt: now + windowMs });
    return false;
  }

  const limit = rateLimiter.get(socketId);
  if (now > limit.resetAt) {
    limit.count = 1;
    limit.resetAt = now + windowMs;
    return false;
  }

  if (limit.count >= maxEvents) return true;
  limit.count++;
  return false;
}

io.on('connection', (socket) => {
  socket.on('make_move', (data) => {
    if (isRateLimited(socket.id)) {
      socket.emit('error', { message: 'Quá nhiều request, chờ chút!' });
      return;
    }
    // Xử lý bình thường
  });
});

Deploy lên production: Vercel, Railway và các lựa chọn cho dev Việt

Vercel là lựa chọn phổ biến để host React app - nhưng Socket.io server thì không chạy được trên Vercel. Lý do: Vercel là serverless, mỗi request chạy trên một function instance độc lập, không duy trì trạng thái hay persistent connection.

Giải pháp thực tế cho dev Việt: tách deploy.

342984 Deploy tách frontend/backend - Vercel cho React, Railway/Render cho Node.js

Platform Phù hợp Giá Ghi chú
Vercel React frontend Free Không hỗ trợ WebSocket
Railway Node.js server ~$5/tháng Hỗ trợ persistent connection
Render Node.js server Free tier Sleep sau 15 phút idle
Fly.io Full stack Free tier VM thật, không serverless
VPS (DigitalOcean/Vultr) Full control ~$6/tháng Cần tự setup

Deploy Node.js Socket.io lên Railway

# Cài Railway CLI
npm install -g @railway/cli

# Login và tạo project
railway login
railway init

# Deploy
railway up

Thêm start script vào package.json:

{
  "scripts": {
    "start": "node server/index.js"
  }
}

Sau khi deploy, Railway cung cấp URL dạng https://your-app.railway.app. Set environment variable CLIENT_URL trỏ về Vercel URL của React app.

Cấu hình CORS đúng cho production

// Quan trọng: CORS phải khớp chính xác với domain frontend
const io = new Server(httpServer, {
  cors: {
    // Không dùng '*' trong production - dễ bị abuse
    origin: [
      'https://your-app.vercel.app',
      'https://your-custom-domain.vn',
    ],
    credentials: true,
  },
  // Giảm ping interval để detect disconnect nhanh hơn
  pingTimeout: 60000,
  pingInterval: 25000,
});

Health check endpoint

// Thêm endpoint để load balancer và monitoring check
app.get('/health', (req, res) => {
  res.json({
    status: 'ok',
    connections: io.engine.clientsCount,
    uptime: process.uptime(),
  });
});

Với setup này, React app trên Vercel kết nối Socket.io trên Railway - toàn bộ miễn phí hoặc chi phí rất thấp, đủ cho sản phẩm giai đoạn MVP đến vài nghìn user đồng thời.


Tối ưu performance và debug Socket.io

App realtime có một đặc điểm khác app thường: bug về performance thường không lộ ngay khi test, mà chỉ xuất hiện khi có nhiều user đồng thời. Mình chia sẻ những vấn đề mình từng gặp.

Vấn đề 1: Memory leak do không cleanup listener

// SAI - mỗi lần component re-render sẽ đăng ký listener mới
useEffect(() => {
  socket.on('new_message', handleMessage);
}); // Không có dependency array!

// ĐÚNG - cleanup listener khi component unmount
useEffect(() => {
  socket.on('new_message', handleMessage);
  
  return () => {
    socket.off('new_message', handleMessage); // Bắt buộc phải cleanup
  };
}, []); // Dependency array rỗng = chỉ chạy 1 lần

Thiếu cleanup là lỗi phổ biến nhất. Sau vài giờ dùng, app bắt đầu chậm dần vì hàng nghìn listener chồng lên nhau.

Vấn đề 2: Emit quá nhiều data

// SAI - gửi cả object user khổng lồ
io.to(roomId).emit('player_update', { ...fullUserObject }); // 10KB/event

// ĐÚNG - chỉ gửi những gì client cần để render
io.to(roomId).emit('player_update', {
  id: user.id,
  x: user.position.x,
  y: user.position.y,
  score: user.score,
}); // 50 bytes/event

Với game di chuyển liên tục (60fps), gửi 10KB/frame cho 100 người chơi = 60MB bandwidth/giây. Gửi 50 bytes/frame = 300KB/giây. Tối ưu payload là bắt buộc cho game action.

342985 Nhỏ hơn = nhanh hơn - tối ưu payload là điểm khác biệt app OK vs app tốt

Vấn đề 3: Debug với Socket.io Admin UI

// Thêm vào server để có dashboard monitor connection
const { instrument } = require('@socket.io/admin-ui');

instrument(io, {
  auth: {
    type: 'basic',
    username: 'admin',
    password: process.env.ADMIN_PASSWORD,
  },
});

Truy cập Socket.io Admin UI để xem realtime: số connection, event flow, room state. Cực kỳ hữu ích khi debug production issue.

Checklist trước khi go-live

  • Có cleanup tất cả socket.on() listener trong React không?
  • CORS chỉ whitelist domain cụ thể, không dùng *?
  • Rate limiting để tránh spam event?
  • Health check endpoint cho monitoring?
  • Error handling: server emit error event, client catch và hiển thị?
  • Test trường hợp mất mạng và reconnect?

Bước tiếp theo từ đây:

  1. Clone code server + client mình đã viết, chạy local với 2 tab browser
  2. Thêm một tính năng nhỏ: xem lịch sử nước đi hoặc thêm chat trong phòng game
  3. Deploy lên Railway + Vercel theo flow ở section trước

Gặp vướng mắc ở bước nào, drop comment - mình sẽ trả lời.