Thay Supabase Realtime bằng Socket.IO/Pusher: Hướng dẫn thực chiến

18/05/2026 · P T P · Chung

Thay thế Supabase Realtime bằng Socket.IO hoặc Pusher: hướng dẫn thực chiến

Supabase Realtime mạnh khi cần nghe thay đổi từ Postgres nhanh, ít cấu hình, hợp MVP. Nhưng khi sản phẩm lớn hơn, nhu cầu thường đổi: cần kiểm soát kênh tốt hơn, giảm tải database, gom sự kiện nghiệp vụ, scale theo kiểu riêng, hoặc chạy đa nền tảng với độ trễ ổn định hơn. Lúc đó, thay Supabase Realtime bằng Socket.IO hoặc Pusher là lựa chọn đáng cân nhắc.

Bài này đi thẳng vào thực chiến: khi nào nên thay, chọn Socket.IO hay Pusher, kiến trúc nên dùng, cách migrate từng bước, và lỗi hay gặp.

Vì sao thay Supabase Realtime?

Supabase Realtime nghe thay đổi qua replication/Postgres changes. Tiện, nhưng có vài giới hạn thực tế.

Database thành nguồn phát event

Khi client subscribe trực tiếp vào thay đổi bảng, database không chỉ lưu dữ liệu mà còn thành “event broker”. Với app nhỏ ổn. Với app nhiều người dùng, nhiều bảng, nhiều filter, chi phí và độ phức tạp tăng.

Ví dụ: chat, notification, live dashboard, presence. Không phải thay đổi nào trong DB cũng nên bắn ra client. Nhiều event nên là event nghiệp vụ, không phải row-level change.

Khó kiểm soát quyền theo ngữ cảnh

Supabase có RLS rất mạnh. Nhưng realtime permission có thể khó debug khi rule phức tạp: theo team, role, trạng thái invoice, project membership, tenant.

Nếu chuyển sang backend tự phát event, ta có thể kiểm tra quyền trong service layer rồi emit đúng người, đúng room.

Cần pattern realtime khác

Một số case cần:

Presence: ai đang online, đang gõ, đang xem document.
Typing indicator: event ngắn hạn, không cần ghi DB.
Job progress: trạng thái xử lý file, AI task, import CSV.
Notification fanout: gửi nhiều user, nhiều device.
Game/livestream interaction: tần suất cao, không phù hợp DB-trigger realtime.

Những thứ này hợp WebSocket/event broker hơn Postgres changes.

Socket.IO hay Pusher?

Socket.IO: kiểm soát cao, tự vận hành

Socket.IO chạy trên server của bạn. Có fallback transport, room, namespace, middleware auth, adapter Redis để scale ngang.

Hợp khi:

– Có backend Node.js/NestJS/Express.
– Muốn kiểm soát protocol, auth, room, payload.
– Muốn tự host để giảm chi phí dài hạn.
– Cần event tần suất cao.
– Có hạ tầng Redis, load balancer, observability.

Đổi lại:

– Phải vận hành server realtime.
– Phải xử lý scale, reconnect, monitoring.
– Cần cấu hình sticky session hoặc Redis adapter đúng.

Pusher: dịch vụ managed, triển khai nhanh

Pusher Channels cung cấp WebSocket managed. Server chỉ gọi API trigger event. Client subscribe channel.

Hợp khi:

– Muốn đi nhanh, ít vận hành.
– Team nhỏ, không muốn quản lý WebSocket server.
– App cần realtime vừa phải: notification, dashboard, chat.
– Cần uptime ổn, dashboard sẵn.

Đổi lại:

– Chi phí tăng theo connection/message.
– Bị lock-in API/channel model.
– Ít kiểm soát tầng transport hơn Socket.IO.

Kiến trúc thay thế nên dùng

Mục tiêu: không để client nghe database trực tiếp nữa. Backend thành nơi tạo event.

Luồng chuẩn

1. Client gọi API mutation: tạo message, cập nhật task, đổi status.
2. Backend validate auth và business rule.
3. Backend ghi DB.
4. Backend emit event qua Socket.IO hoặc Pusher.
5. Client nhận event, cập nhật UI cache.

Ví dụ:

Client -> API -> Database
              -> Realtime Gateway -> Client subscribers

Điểm mấu chốt: event phát sau khi DB ghi thành công. Tránh gửi event rồi DB fail.

Event nghiệp vụ thay vì row change

Đừng emit kiểu:

{
  "table": "messages",
  "type": "INSERT",
  "record": {}
}

Nên emit kiểu:

{
  "event": "message.created",
  "conversationId": "c_123",
  "message": {
    "id": "m_1",
    "text": "Hello",
    "senderId": "u_1",
    "createdAt": "2026-05-17T10:00:00Z"
  }
}

Lý do: client hiểu nghiệp vụ, ít phụ thuộc schema DB, dễ version payload.

Thiết kế channel/room

Theo entity

conversation:{id}
project:{id}
task:{id}
organization:{id}

Hợp cho chat, project dashboard, collaborative app.

Theo user

user:{id}:notifications
user:{id}:devices

Hợp cho notification cá nhân, job progress, billing alert.

Theo tenant

org:{id}:feed
workspace:{id}:presence

Hợp SaaS multi-tenant. Cần auth chặt, tránh leak dữ liệu giữa tenant.

Auth và phân quyền

Socket.IO auth

Client gửi token khi connect:

const socket = io("https://api.example.com", {
  auth: { token: accessToken }
});

Server xác thực:

io.use(async (socket, next) => {
  try {
    const user = await verifyJwt(socket.handshake.auth.token);
    socket.user = user;
    next();
  } catch (e) {
    next(new Error("unauthorized"));
  }
});

Khi join room, kiểm tra quyền:

socket.on("conversation:join", async ({ conversationId }) => {
  const ok = await canAccessConversation(socket.user.id, conversationId);
  if (!ok) return socket.emit("error", { code: "FORBIDDEN" });

socket.join(conversation:${conversationId}); });

Pusher private channel

Pusher dùng endpoint auth riêng. Client yêu cầu subscribe private channel, server ký nếu user có quyền.

app.post("/pusher/auth", async (req, res) => {
  const user = await requireUser(req);
  const { socket_id, channel_name } = req.body;

const ok = await canAccessChannel(user.id, channel_name); if (!ok) return res.status(403).send("Forbidden");

const auth = pusher.authorizeChannel(socket_id, channel_name); res.send(auth); });

Tên channel thường là:

private-conversation-c_123
private-user-u_1

Migration từng bước từ Supabase Realtime

Bước 1: Liệt kê subscription hiện có

Tạo bảng mapping:

| Supabase subscription | Mục đích | Event mới | Channel mới |
|—|—|—|—|
| messages INSERT | Tin nhắn mới | message.created | conversation:{id} |
| tasks UPDATE | Task đổi trạng thái | task.updated | project:{id} |
| notifications INSERT | Thông báo | notification.created | user:{id}:notifications |

Việc này giúp tránh migrate mù.

Bước 2: Đưa mutation về backend

Nếu client đang insert trực tiếp Supabase:

await supabase.from("messages").insert({ text, conversation_id });

Chuyển thành:

await fetch("/api/messages", {
  method: "POST",
  body: JSON.stringify({ text, conversationId })
});

Backend xử lý insert rồi emit.

Bước 3: Emit song song

Trong giai đoạn chuyển đổi, có thể chạy song song:

– Supabase Realtime vẫn hoạt động.
– Socket.IO/Pusher emit event mới.
– Một nhóm client dùng event mới qua feature flag.

Mục tiêu: so sánh độ trễ, lỗi permission, thiếu event.

Bước 4: Chuyển client cache

Nếu dùng React Query/TanStack Query, khi nhận event:

socket.on("message.created", (payload) => {
  queryClient.setQueryData(
    ["messages", payload.conversationId],
    (old = []) => [...old, payload.message]
  );
});

Với Pusher:

channel.bind("message.created", (payload) => {
  queryClient.invalidateQueries({
    queryKey: ["messages", payload.conversationId]
  });
});

setQueryData nhanh hơn, nhưng dễ lệch nếu payload thiếu. invalidateQueries an toàn hơn, nhưng tốn request.

Bước 5: Tắt Supabase Realtime theo module

Đừng tắt toàn bộ ngay. Tắt theo từng feature:

1. Notification
2. Chat
3. Dashboard
4. Presence/job progress

Mỗi module cần log: số event emit, số client receive, reconnect rate, error rate.

Dùng Socket.IO trong production

Scale ngang với Redis adapter

Nếu có nhiều instance Node.js, room nằm rải rác. Cần Redis adapter:

import { createAdapter } from "@socket.io/redis-adapter";
import { createClient } from "redis";

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

await pubClient.connect(); await subClient.connect();

io.adapter(createAdapter(pubClient, subClient));

Không có adapter, emit từ instance A không tới socket đang nằm ở instance B.

Load balancer

Cần hỗ trợ WebSocket upgrade. Với Socket.IO, nên bật sticky session nếu dùng polling fallback. Nếu chỉ dùng websocket transport, yêu cầu sticky giảm nhưng vẫn cần test kỹ.

const socket = io(url, {
  transports: ["websocket"]
});

Quan sát hệ thống

Cần metric:

– active connections
– events per second
– room count
– reconnect count
– emit failure
– average latency
– Redis pub/sub errors

Realtime hỏng thường không hỏng rõ như REST API. Không log sẽ khó tìm lỗi.

Dùng Pusher trong production

Giới hạn payload

Pusher có giới hạn kích thước message. Đừng gửi object quá lớn. Gửi ID + dữ liệu cần hiển thị, hoặc gửi ID rồi client fetch thêm.

{
  "event": "task.updated",
  "taskId": "t_1",
  "status": "done"
}

Batch và rate limit

Nếu import 10.000 row và emit 10.000 event, client sẽ nghẹt. Gom event:

{
  "event": "import.progress",
  "jobId": "j_1",
  "processed": 5000,
  "total": 10000
}

Emit mỗi 500ms hoặc mỗi 1-5% tiến độ.

Outbox pattern: chống mất event

Vấn đề thực tế: DB ghi xong, server crash trước khi emit. Client không nhận event.

Giải pháp chắc hơn: outbox table.

1. Trong cùng transaction, ghi business data và ghi event vào event_outbox.
2. Worker đọc outbox chưa gửi.
3. Worker emit qua Socket.IO/Pusher.
4. Đánh dấu sent_at.

Schema tối giản:

create table event_outbox (
  id uuid primary key,
  event_name text not null,
  channel text not null,
  payload jsonb not null,
  created_at timestamptz default now(),
  sent_at timestamptz
);

Pattern này tăng độ tin cậy, nhất là với payment, notification quan trọng, workflow nhiều bước.

Lỗi hay gặp

Emit trước khi commit

Nếu emit trước khi transaction commit, client fetch lại có thể chưa thấy dữ liệu. Luôn emit sau commit, hoặc dùng outbox.

Trust client quá mức

Client không được tự quyết join project:123. Server phải kiểm tra quyền.

Payload phụ thuộc DB schema

Nếu rename column là client vỡ. Dùng event contract ổn định.

Không xử lý reconnect

Client mất mạng rồi kết nối lại có thể miss event. Khi reconnect, nên refetch dữ liệu quan trọng:

socket.on("connect", () => {
  queryClient.invalidateQueries({ queryKey: ["notifications"] });
});

Realtime chỉ là lớp đồng bộ nhanh, không nên là nguồn sự thật duy nhất.

Kết luận thực tế

Supabase Realtime tốt cho khởi đầu, nhưng không phải lúc nào cũng là nền tảng realtime dài hạn. Nếu cần kiểm soát sâu, event tần suất cao, self-host, chọn Socket.IO. Nếu muốn triển khai nhanh, ít vận hành, trả tiền để lấy sự ổn định, chọn Pusher.

Cách migrate an toàn: đổi từ row-level change sang event nghiệp vụ, đưa mutation về backend, thiết kế channel rõ, kiểm tra quyền khi subscribe, chạy song song, rồi tắt Supabase Realtime từng module. Với hệ thống quan trọng, thêm outbox pattern để không mất event.

Realtime tốt không chỉ là “bắn event nhanh”. Realtime tốt là đúng quyền, đúng thời điểm, không mất dữ liệu, scale được, và client vẫn hồi phục tốt khi mạng xấu.

#bang #realtime #socket #supabase #thay
Chia sẻ:
← Trước
Supabase không còn hợp? Checklist chọn backend thay thế

Bài viết tương tự

Bình luận

Chưa có bình luận. Hãy là người đầu tiên!