September 27, 2026
WebSocket & Aplikasi Real-time.
Dari polling sampai WebSocket: cara kerja handshake dan frame, heartbeat, reconnect dengan backoff, autentikasi saat koneksi, room dan pub/sub, scaling horizontal dengan Redis, serta backpressure. Dilengkapi contoh dari aplikasi chat produksi.
HTTP dirancang untuk pola request → response: klien bertanya, server menjawab, selesai. Aplikasi real-time membalik arah itu. Server-lah yang perlu bicara lebih dulu, misalnya “ada pesan baru”, “agen lain sedang mengetik”, atau “proses clone VM sudah 60%”.
Catatan ini membedah cara mencapainya, dari teknik paling sederhana sampai WebSocket, lalu masuk ke masalah yang baru muncul di produksi: koneksi yang mati diam-diam, ribuan klien yang reconnect bersamaan, dan server yang lebih dari satu. Contoh diambil dari Chat App Multiapps , dashboard customer support omnichannel berbasis Fastify dan Socket.IO, serta Proxmox Management Server yang memakai WebSocket untuk progres task dan konsol VM .
Empat Cara Mendapatkan Data “Real-time”
1. Short Polling
Klien bertanya berkala: “ada yang baru?”
setInterval(async () => {
const res = await fetch("/api/messages?since=" + lastId);
const data = await res.json();
if (data.length) render(data);
}, 3000);
Sederhana dan bekerja di mana saja. Tapi ada dua biaya yang tidak bisa dihindari:
- Latensi rata-rata setengah interval. Dengan interval 3 detik, pesan baru terlihat rata-rata 1,5 detik kemudian.
- Pemborosan. Sebagian besar request menjawab “tidak ada apa-apa”, tetapi tetap membawa header, cookie, TLS, dan query database. Dengan 1.000 klien dan interval 3 detik, server menerima sekitar 333 request per detik hanya untuk mengatakan “belum ada”.
2. Long Polling
Server menahan request sampai ada data (atau timeout), lalu klien langsung membuka request baru.
async function poll() {
try {
const res = await fetch("/api/messages/wait?since=" + lastId); // server menahan ~25 detik
const data = await res.json();
if (data.length) render(data);
} finally {
poll(); // segera buka lagi
}
}
Latensi jauh lebih baik, karena data dikirim begitu tersedia. Kekurangannya, setiap pesan tetap membutuhkan satu siklus request baru, server harus menahan banyak koneksi terbuka, dan pesan yang tiba di antara dua request bisa terlewat bila server tidak menyimpan kursor. Long polling adalah transport fallback bawaan Socket.IO.
3. Server-Sent Events (SSE)
Satu respons HTTP yang tidak pernah selesai. Server menulis event sebagai teks dengan format sederhana:
event: task-progress
id: 42
data: {"taskId":"clone-101","percent":60}
Di browser, EventSource sudah menangani parsing dan reconnect otomatis. Saat tersambung ulang, browser mengirim header Last-Event-ID berisi id terakhir yang diterima, sehingga server bisa melanjutkan dari titik itu. Field retry mengatur jeda reconnect dalam milidetik.
const es = new EventSource("/api/tasks/stream");
es.addEventListener("task-progress", (e) => update(JSON.parse(e.data)));
SSE sering diremehkan, padahal cocok untuk banyak kasus: notifikasi, progres job, feed harga, streaming token LLM. Batasannya:
- Satu arah (server → klien). Klien tetap mengirim data lewat
fetchbiasa. - Hanya teks (UTF-8).
- Di HTTP/1.1, browser membatasi 6 koneksi per domain, dan batas ini berlaku lintas tab. Tab ketujuh akan menggantung. Di HTTP/2 batasnya dinegosiasikan (umumnya 100 stream), jadi SSE sebaiknya dilayani lewat HTTP/2.
4. WebSocket
Koneksi TCP dua arah dan persisten, dimulai sebagai request HTTP lalu “di-upgrade” menjadi protokol lain. Setelah terbuka, kedua sisi bisa mengirim pesan kapan saja dengan overhead framing hanya beberapa byte.
Perbandingan
| Short polling | Long polling | SSE | WebSocket | |
|---|---|---|---|---|
| Arah | Klien tanya | Klien tanya | Server → klien | Dua arah |
| Latensi | Sampai 1 interval | Rendah | Rendah | Rendah |
| Overhead per pesan | 1 request HTTP penuh | 1 request HTTP penuh | Kecil | Sangat kecil (2 sampai 14 byte header frame) |
| Reconnect | Tidak relevan | Manual | Otomatis (browser) | Manual |
| Biner | Ya | Ya | Tidak | Ya |
| Lewat proxy/CDN | Mudah | Mudah | Perlu matikan buffering | Perlu dukungan upgrade |
Aturan praktis saya: kalau data hanya mengalir dari server, mulai dari SSE. Pilih WebSocket bila klien juga sering mengirim (chat, typing indicator, kolaborasi, terminal interaktif) atau butuh data biner.
Di Balik WebSocket: Handshake dan Frame
Handshake Upgrade
Koneksi WebSocket dimulai sebagai request HTTP/1.1 GET biasa dengan header khusus (contoh ini berasal dari RFC 6455):
GET /chat HTTP/1.1
Host: server.example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13
Origin: http://example.com
Server yang setuju membalas 101 Switching Protocols:
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
Sec-WebSocket-Accept dihitung dengan menggabungkan Sec-WebSocket-Key dengan GUID tetap 258EAFA5-E914-47DA-95CA-C5AB0DC85B11, lalu mengambil SHA-1 dan meng-encode hasilnya ke Base64. Ini bukan mekanisme keamanan. Tujuannya hanya membuktikan bahwa server benar-benar memahami WebSocket, bukan server HTTP biasa yang kebetulan memantulkan header.
import { createHash } from "node:crypto";
const GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
const accept = (key) => createHash("sha1").update(key + GUID).digest("base64");
accept("dGhlIHNhbXBsZSBub25jZQ=="); // "s3pPLMBiTxaQ9kYGzzhZRbK+xOo="
Setelah 101, koneksi TCP yang sama berhenti berbicara HTTP dan mulai bertukar frame.
Satu konsekuensi penting: karena handshake adalah request HTTP, browser mengirim cookie untuk domain tersebut. Server wajib memeriksa header Origin. Kalau tidak, situs lain bisa membuka WebSocket ke servermu dengan sesi pengguna yang sedang login (Cross-Site WebSocket Hijacking).
Struktur Frame
Setiap pesan dikirim dalam satu atau beberapa frame:
0 1 2 3
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-------+-+-------------+-------------------------------+
|F|R|R|R| opcode|M| Payload len | Extended payload length |
|I|S|S|S| (4) |A| (7) | (16/64) |
|N|V|V|V| |S| | |
+-+-+-+-+-------+-+-------------+-------------------------------+
| Masking key (32 bit, hanya jika MASK=1) | Payload data ...
+-------------------------------------------+-------------------
- FIN menandai frame terakhir dari sebuah pesan. Pesan besar bisa dipecah (fragmentation) menjadi beberapa frame.
- Opcode menentukan jenis frame:
| Opcode | Arti |
|---|---|
0x0 | Continuation (lanjutan fragmen) |
0x1 | Text (UTF-8) |
0x2 | Binary |
0x8 | Close |
0x9 | Ping |
0xA | Pong |
- Payload length memakai 7 bit. Nilai 0 sampai 125 adalah panjang sebenarnya, nilai 126 berarti 2 byte berikutnya berisi panjang (16-bit), dan nilai 127 berarti 8 byte berikutnya (64-bit). Karena itu header frame kecil hanya 2 byte.
- Masking. Setiap frame dari klien ke server wajib di-mask dengan kunci acak 32-bit. Tujuannya bukan kerahasiaan, melainkan mencegah cache poisoning pada proxy perantara yang salah menafsirkan byte WebSocket sebagai HTTP. Frame dari server tidak di-mask.
- Control frame (close, ping, pong) payload-nya maksimal 125 byte dan tidak boleh difragmentasi, sehingga bisa diselipkan di tengah pesan besar yang sedang dikirim.
Menutup Koneksi dengan Benar
Penutupan yang bersih adalah close handshake: salah satu sisi mengirim frame Close berisi kode status, sisi lain membalas Close, lalu TCP ditutup. Kode yang sering ditemui:
| Kode | Arti |
|---|---|
1000 | Normal closure |
1001 | Going away (server restart, tab ditutup) |
1006 | Abnormal: koneksi putus tanpa frame Close. Kode ini tidak pernah dikirim lewat jaringan; ini dilaporkan oleh API ketika TCP mati begitu saja |
1008 | Policy violation (misalnya autentikasi gagal) |
1011 | Server mengalami error internal |
Kalau log klienmu dipenuhi 1006, masalahnya ada di jaringan, proxy, atau load balancer yang memutus koneksi idle, bukan di kode aplikasi.
Heartbeat: Mendeteksi Koneksi yang Mati Diam-diam
TCP tidak memberi tahu apa pun ketika laptop pengguna masuk mode sleep, pindah dari Wi-Fi ke seluler, atau ketika NAT router membuang entri koneksi yang idle. Dari sisi server, socket itu tampak masih terbuka (half-open connection). Server terus menyimpan state, menganggap pengguna online, dan mengirim pesan ke lubang hitam.
Solusinya adalah heartbeat: salah satu sisi mengirim ping secara berkala, dan bila pong tidak kembali dalam batas waktu, koneksi dianggap mati dan ditutup paksa.
Dengan library ws di Node.js, polanya seperti ini:
import { WebSocketServer } from "ws";
const wss = new WebSocketServer({ port: 8080 });
wss.on("connection", (ws) => {
ws.isAlive = true;
ws.on("pong", () => { ws.isAlive = true; });
});
const interval = setInterval(() => {
for (const ws of wss.clients) {
if (!ws.isAlive) {
ws.terminate(); // tidak menunggu close handshake
continue;
}
ws.isAlive = false;
ws.ping();
}
}, 30_000);
wss.on("close", () => clearInterval(interval));
Heartbeat juga menjaga koneksi tetap “terlihat aktif” bagi proxy dan load balancer yang memutus koneksi idle setelah beberapa puluh detik.
Socket.IO sudah membawa mekanisme ini. Nilai default-nya pingInterval 25000 ms dan pingTimeout 20000 ms: server mengirim ping setiap 25 detik, dan jika klien tidak membalas dalam 20 detik, koneksi dianggap putus.
Heartbeat di Level Aplikasi
Heartbeat transport hanya menjawab pertanyaan “apakah koneksinya hidup”. Terkadang kamu butuh pertanyaan lain: “apakah pengguna masih melakukan sesuatu”. Di Chatku, agen yang membuka percakapan memegang kunci aktif di Redis dengan TTL 15 detik, dan klien memperbaruinya lewat event conversation:heartbeat:
const ACTIVE_KEY = (convId) => `active:${convId}`;
const ACTIVE_TTL = 15; // detik
socket.on("conversation:open", async ({ conversationId }) => {
socket.join(`conv:${conversationId}`);
await redis.set(
ACTIVE_KEY(conversationId),
JSON.stringify({ agentId: socket.user.id, agentName: socket.user.name }),
"EX", ACTIVE_TTL,
);
socket.to(`conv:${conversationId}`).emit("active:update", {
conversationId, agentId: socket.user.id, isActive: true,
});
});
socket.on("conversation:heartbeat", async ({ conversationId }) => {
const raw = await redis.get(ACTIVE_KEY(conversationId));
if (!raw || JSON.parse(raw).agentId !== socket.user.id) return;
await redis.expire(ACTIVE_KEY(conversationId), ACTIVE_TTL); // perpanjang
});
Kunci ini mencegah dua agen membalas pelanggan yang sama. Keindahan TTL adalah ia membersihkan dirinya sendiri: bila tab agen crash atau jaringannya putus, tidak ada kode yang perlu berjalan. Kunci kedaluwarsa dalam 15 detik dan percakapan kembali bebas.
Reconnect dengan Exponential Backoff dan Jitter
Koneksi pasti putus: deploy, restart server, jaringan seluler. Yang menentukan kualitas aplikasi real-time adalah apa yang terjadi setelahnya.
Pendekatan naif, yaitu reconnect segera dan terus-menerus, memicu thundering herd: server restart, 5.000 klien mencoba tersambung pada milidetik yang sama, server kewalahan dan jatuh lagi, lalu siklusnya berulang.
Solusinya adalah exponential backoff dengan jitter:
function connect(attempt = 0) {
const ws = new WebSocket(URL);
ws.onopen = () => { attempt = 0; };
ws.onclose = (e) => {
if (e.code === 1008) return; // ditolak karena auth: jangan retry membabi buta
const base = Math.min(1000 * 2 ** attempt, 30_000); // 1s, 2s, 4s, ... maks 30s
const delay = base / 2 + Math.random() * (base / 2); // jitter: 50%–100% dari base
setTimeout(() => connect(attempt + 1), delay);
};
}
- Eksponensial memberi server napas ketika masalahnya berlangsung lama.
- Batas atas (cap) mencegah jeda menjadi berjam-jam.
- Jitter menyebar klien di sepanjang jendela waktu sehingga mereka tidak kembali serentak.
- Jangan retry pada error autentikasi. Token yang kedaluwarsa tidak akan valid pada percobaan ke-100. Refresh token dulu, atau arahkan pengguna ke login.
Socket.IO client sudah melakukan ini secara default: reconnectionDelay 1000 ms, reconnectionDelayMax 5000 ms, randomizationFactor 0.5, dan reconnectionAttempts tak terbatas. Percobaan pertama jatuh di antara 500 dan 1500 ms, lalu delay berlipat dua sampai mentok di 5 detik.
Reconnect Bukan Akhir: Sinkronisasi State
Setelah tersambung kembali, klien sudah melewatkan event selama terputus. Ada beberapa strategi untuk mengatasinya:
- Refetch penuh. Setelah
connect, ambil ulang data lewat REST. Paling sederhana dan paling andal. - Kursor. Klien mengirim ID event terakhir yang diterima, dan server mengirim yang terlewat. Ini prinsip yang sama dengan
Last-Event-IDdi SSE. - Connection state recovery Socket.IO, yang mengirim ulang paket yang terlewat dan memulihkan
socket.idserta room bila klien kembali dalam jendela waktu tertentu (default 2 menit). Keberhasilannya bisa dicek lewatsocket.recovered.
Ada jebakan halus di opsi ketiga yang saya temukan saat menulis catatan ini. Dokumentasi Socket.IO menyebut fitur ini tidak kompatibel dengan Redis adapter klasik (yang berbasis Pub/Sub), karena paket tidak bisa dipersistenkan lewat Pub/Sub. Yang didukung adalah adapter bawaan in-memory, Redis Streams adapter, dan MongoDB adapter. Konfigurasi seperti ini:
const io = new Server(fastify.server, {
connectionStateRecovery: { maxDisconnectionDuration: 30000 },
});
io.adapter(createAdapter(pubClient, subClient)); // @socket.io/redis-adapter
tidak akan error, tapi pemulihan state-nya tidak bisa diandalkan saat klien tersambung ke instance lain. Jadi di multi-instance, tetap sediakan strategi 1 atau 2 sebagai jaring pengaman, atau pindah ke Redis Streams adapter.
Autentikasi Saat Koneksi
API WebSocket di browser tidak bisa menambahkan header kustom seperti Authorization. Pilihannya:
| Cara | Catatan |
|---|---|
| Cookie sesi | Otomatis terkirim; wajib cek Origin untuk mencegah hijacking |
| Token di query string | Mudah, tapi URL bisa tercatat di log proxy dan server |
Token di subprotocol (Sec-WebSocket-Protocol) | Trik yang cukup umum, tapi menyalahgunakan semantik header |
| Pesan pertama berisi token | Koneksi terbuka sebelum terautentikasi, jadi terapkan timeout singkat |
| Tiket sekali pakai | Ambil tiket berumur pendek via REST, lalu kirim saat connect |
Socket.IO menyelesaikannya dengan field auth yang dikirim bersama handshake, dan middleware io.use() yang berjalan sebelum event connection. Middleware auth di Chatku kurang lebih seperti ini:
io.use(async (socket, next) => {
try {
const token =
socket.handshake.auth?.token ||
socket.handshake.headers?.authorization?.replace("Bearer ", "");
if (!token) return next(new Error("Authentication required"));
const payload = await fastify.jwt.verify(token);
// Multi-tenant: tolak realtime untuk workspace yang di-suspend,
// konsisten dengan pemeriksaan di jalur HTTP.
const org = await prisma.organization.findFirst({
where: { id: payload.org },
select: { status: true, deletedAt: true },
});
if (!org || org.deletedAt || org.status === "suspended") {
return next(new Error("Workspace suspended"));
}
socket.user = { id: payload.sub, organizationId: payload.org, role: payload.role };
next();
} catch {
next(new Error("Invalid or expired token"));
}
});
Di sisi klien:
const socket = io(SOCKET_URL, {
auth: { token: accessToken },
reconnection: true,
reconnectionDelay: 1000,
});
socket.on("connect_error", (err) => {
if (err.message === "Invalid or expired token") refreshTokenThenReconnect();
});
Tiga pelajaran penting:
- Autentikasi di handshake saja tidak cukup untuk otorisasi. Token valid membuktikan siapa pengguna, bukan apa yang boleh ia akses. Setiap event yang menyentuh resource (
conversation:opendenganconversationIdtertentu) harus memeriksa bahwa resource itu memang milik tenant atau pengguna tersebut. Kalau tidak, cukup mengganti ID di DevTools untuk bergabung ke room percakapan tenant lain. - Token kedaluwarsa di tengah koneksi. JWT diverifikasi saat connect, tapi koneksi bisa hidup berjam-jam setelah token habis. Untuk izin yang sensitif, simpan waktu kedaluwarsa di
socket.datadan putuskan koneksi saat terlewat, atau lakukan pengecekan ulang secara berkala. - Pisahkan namespace untuk tipe klien berbeda. Chatku memakai namespace utama untuk agen (wajib JWT) dan namespace
/widgetuntuk pengunjung website (hanya session ID, tanpa JWT). Middleware berbeda per namespace membuat aturan akses tidak saling bocor.
Room dan Pub/Sub
Mengirim ke “semua orang” jarang diperlukan. Yang dibutuhkan biasanya “semua agen yang sedang membuka percakapan X” atau “semua orang di tenant Y”. Di sinilah room berperan, yaitu grup socket yang bisa di-broadcast:
socket.join(`conv:${conversationId}`); // yang membuka percakapan ini
socket.join(`inbox:${inboxId}`); // yang memantau inbox ini
socket.join(`org:${orgId}`); // semua agen di tenant ini
io.to(`conv:${conversationId}`).emit("message:new", msg); // ke semua di room
socket.to(`conv:${conversationId}`).emit("typing:update", t); // ke semua KECUALI pengirim
Konvensi penamaan room adalah bagian dari desain keamanan. Dengan memberi prefix jenis resource dan memakai UUID (bukan ID berurutan), room antar-tenant tidak mungkin bertabrakan dan sulit ditebak.
Room adalah bentuk sederhana dari pola publish/subscribe: pengirim tidak tahu siapa penerimanya, ia hanya menerbitkan ke sebuah topik.
Jangan Lupakan disconnecting
Saat koneksi putus tanpa sempat mengirim “close” (tab ditutup), state yang terikat pada socket harus dibersihkan. Socket.IO punya dua event: disconnecting dan disconnect. Bedanya penting:
// Di 'disconnecting', socket.rooms masih berisi room yang diikuti.
// Di 'disconnect', daftar room sudah kosong.
socket.on("disconnecting", async () => {
const convIds = [...socket.rooms]
.filter((r) => r.startsWith("conv:"))
.map((r) => r.slice("conv:".length));
for (const id of convIds) {
await releaseProvisionalAssignment(id, socket.user.id);
}
});
Chatku memakai ini untuk melepas assignment otomatis milik agen yang menutup tab di tengah percakapan. Kalau logika yang sama ditaruh di disconnect, daftar percakapannya sudah hilang.
Scaling Horizontal dengan Redis
Satu proses Node.js bisa menangani cukup banyak koneksi. Tapi begitu kamu menjalankan dua instance (demi ketersediaan atau kapasitas), muncul masalah mendasar:
Agen A ──ws──▶ Instance 1 Instance 2 ◀──ws── Agen B
│
io.to("conv:42").emit(...) ← Agen B tidak menerima!
Setiap instance hanya tahu socket yang terhubung ke dirinya sendiri. Room conv:42 di instance 1 tidak berisi agen B.
Redis Adapter
Adapter Socket.IO menyelesaikan ini dengan Redis Pub/Sub. Setiap broadcast ke room dikirim ke klien lokal yang cocok dan di-publish ke channel Redis. Instance lain yang men-subscribe channel itu lalu meneruskannya ke klien lokal mereka.
const Redis = require("ioredis");
const { createAdapter } = require("@socket.io/redis-adapter");
const pubClient = new Redis(REDIS_URL);
const subClient = pubClient.duplicate(); // koneksi subscriber harus terpisah
io.adapter(createAdapter(pubClient, subClient));
Koneksi subscriber harus terpisah karena koneksi Redis yang sedang dalam mode SUBSCRIBE tidak bisa menjalankan perintah biasa. Untuk proyek baru dengan Redis 7 ke atas, dokumentasi Socket.IO menyarankan sharded adapter yang memanfaatkan sharded Pub/Sub agar lebih skalabel.
Sticky Session Tetap Wajib
Redis adapter tidak menghilangkan kebutuhan sticky session bila transport long-polling diaktifkan. Default Socket.IO adalah ["polling", "websocket"]: koneksi dimulai dengan beberapa request HTTP polling sebelum di-upgrade. Kalau request kedua mendarat di instance yang berbeda, instance itu tidak mengenal sesinya dan membalas HTTP 400. Load balancer harus mengarahkan klien yang sama ke instance yang sama, misalnya lewat cookie atau hash IP. Alternatifnya, batasi klien ke transports: ["websocket"] dan terima bahwa klien di jaringan yang memblokir WebSocket tidak bisa tersambung.
State Bersama Harus Keluar dari Memori Proses
Adapter hanya menyelesaikan broadcast. State lain juga harus dipindahkan ke tempat bersama:
- Presence dan lock (siapa online, siapa memegang percakapan): simpan di Redis dengan TTL, seperti kunci aktif di atas.
- Cache per-proses boleh, asal toleran terhadap data basi. Chatku menyimpan cache kecil
Mapuntuk metadata widget dengan TTL 30 detik, supaya burst keystroke agen tidak menghantam database di setiap penekanan tombol. - Klaim atomik. Dua agen di dua instance bisa mengetik bersamaan. Jangan membaca lalu menulis (
if (!conv.assignee) update(...)), karena keduanya bisa membacanull. Gunakan update bersyarat yang atomik di database, misalnyaUPDATE ... WHERE assigned_agent_id IS NULL, lalu periksa jumlah baris yang terpengaruh.
Redis sebagai Titik Tunggal
Dengan desain ini, Redis menjadi dependensi kritis. Kalau Redis mati, broadcast antar-instance berhenti. Pantau Redis, aktifkan persistence atau replikasi sesuai kebutuhan, dan bersihkan koneksi saat shutdown agar proses bisa berhenti dengan rapi:
fastify.addHook("onClose", (_instance, done) => {
Promise.allSettled([pubClient.quit(), subClient.quit()])
.finally(() => io.close(done));
});
Backpressure: Ketika Pengirim Lebih Cepat dari Penerima
WebSocket di atas TCP menjamin urutan dan keandalan pengiriman, tapi tidak menjamin penerima sanggup mengikuti. Bayangkan server mengirim log build 10 MB/detik ke klien di jaringan seluler yang hanya sanggup 1 MB/detik. Data yang belum terkirim menumpuk di buffer memori server, dan dengan banyak klien lambat, proses kehabisan memori.
Tanda-tandanya bisa dibaca dari bufferedAmount, yaitu jumlah byte yang sudah di-queue tetapi belum dikirim ke jaringan. Properti ini ada di API WebSocket browser dan juga di objek socket library ws.
const HIGH_WATER = 1 * 1024 * 1024; // 1 MB
function sendLog(ws, chunk) {
if (ws.bufferedAmount > HIGH_WATER) {
// Pilih strateginya sesuai jenis data:
// - drop (data yang cepat basi: posisi kursor, harga, progres)
// - gabung/coalesce (kirim hanya nilai terbaru)
// - pause sumber dan lanjutkan saat buffer turun
return false;
}
ws.send(chunk);
return true;
}
Strateginya bergantung pada semantik data:
| Jenis data | Strategi |
|---|---|
| Progres, posisi, indikator mengetik | Drop atau coalesce. Hanya nilai terbaru yang penting |
| Pesan chat | Jangan drop. Simpan di database; klien mengambil yang terlewat saat reconnect |
| Stream besar (log, file, terminal) | Pause sumber sampai buffer turun di bawah ambang |
Socket.IO menyediakan socket.volatile.emit(...) untuk kategori pertama: event dibuang bila koneksi belum siap, alih-alih di-buffer.
Ada juga backpressure ke arah sebaliknya. Klien bisa membanjiri server dengan event. Batasi ukuran pesan (Socket.IO maxHttpBufferSize default 1 MB, dan koneksi ditutup bila terlewati), terapkan rate limit per socket untuk event yang mahal, dan lakukan debounce di klien untuk event berfrekuensi tinggi seperti typing.
Memilih Arsitektur: Ringkasan dari Dua Proyek
Chatku membutuhkan komunikasi dua arah yang intens (pesan, typing, presence, lock), sehingga memakai Socket.IO dengan Redis adapter. Pesan masuk dari WhatsApp, Telegram, dan kanal lain tidak dikirim langsung ke socket. Pesan itu lewat webhook, masuk antrean BullMQ , diproses worker, disimpan ke database, dan baru di-broadcast. Socket hanyalah lapisan notifikasi; sumber kebenarannya tetap database. Karena itu klien yang reconnect cukup mengambil ulang data, dan tidak ada pesan yang hilang hanya karena socket sempat putus.
Proxmox Management Server memakai WebSocket untuk dua hal yang sangat berbeda: progres task (satu arah, sebenarnya bisa juga dengan SSE) dan proxy konsol noVNC serta xterm.js ke hypervisor (biner, dua arah, sensitif latensi). Untuk proxy konsol, backpressure dan autentikasi per sesi jauh lebih penting daripada fitur room.
Checklist Produksi
- Pilih transport paling sederhana yang memenuhi kebutuhan (SSE sebelum WebSocket).
- Validasi
Originpada handshake bila memakai cookie. - Autentikasi di handshake, dan otorisasi di setiap event yang menyentuh resource.
- Heartbeat aktif; timeout idle di proxy/load balancer lebih panjang dari interval ping.
- Reconnect dengan exponential backoff, jitter, dan cap; tanpa retry pada error auth.
- Strategi resinkronisasi setelah reconnect (refetch atau kursor).
- Multi-instance: Redis adapter, sticky session (atau WebSocket-only), dan state bersama di Redis.
- Bersihkan state di
disconnecting, dan pakai TTL untuk hal yang harus kedaluwarsa sendiri. - Pantau
bufferedAmount, batasi ukuran pesan, dan pakai rate limit per socket. - Graceful shutdown: tutup koneksi Redis dan server socket dengan rapi.
Kesimpulan
WebSocket sendiri adalah protokol yang cukup sederhana: satu handshake HTTP, lalu aliran frame kecil dua arah. Kerumitan aplikasi real-time hampir tidak pernah ada di protokolnya, melainkan di segala hal yang terjadi ketika koneksi tidak ideal: koneksi mati diam-diam, klien yang kembali serentak, server yang lebih dari satu, dan penerima yang lebih lambat dari pengirim.
Prinsip yang paling berguna dari pengalaman membangun Chatku: perlakukan socket sebagai jalur notifikasi, bukan sumber kebenaran. Simpan data di database, gunakan socket untuk memberi tahu bahwa ada yang berubah, dan pastikan klien selalu bisa mengejar ketinggalan setelah tersambung kembali.
Lihat juga: Streaming Assistant , bot live chat YouTube yang mendorong alert ke overlay OBS lewat WebSocket.

Hey! I’m Fanny, the software engineer tending to this digital garden. You can read more about me, or subscribe by email.