Horizontal scaling artinya menambah jumlah proses atau mesin server, bukan membeli satu mesin yang lebih besar. Untuk WebSocket ini lebih rumit daripada HTTP biasa, karena koneksinya stateful: tiap koneksi menempel di satu proses selama hidupnya, lengkap dengan room-nya. Bayangin Laundry Bu Rini membuka kasir kedua. Pelanggan yang sedang dilayani di kasir 1 harus tetap kembali ke kasir 1 sampai urusannya selesai, karena catatannya ada di meja kasir 1. Dan kalau Mas Eko mengumumkan sesuatu ke kasir 1, kasir 2 baru tahu kalau ada interkom di antara keduanya.
🧪 Tebakan cepat: Server realtime laundry sekarang dua proses Socket.IO, A dan B, di belakang nginx yang membagi request bergiliran (round robin). HP Pak Darto membuka halaman dengan pengaturan client bawaan. Tersambungkah dia? Jawabannya ada di bagian "Load balancer dan sticky session".
Dulu hitung: satu proses kuat menampung berapa?
Setiap koneksi WebSocket memakan dua hal di server: memori dan satu file descriptor (nomor yang dipakai sistem operasi untuk setiap file atau socket yang terbuka). Diukur dengan Node 22, server memegang N koneksi WebSocket dari client di proses lain, memori dibaca setelah garbage collection:
| Koneksi | ws 8.22.0 (RSS) |
socket.io 4.8.4 (RSS) |
|---|---|---|
| 0 | 53,9 MB | 59,6 MB |
| 1.000 | 58,2 MB | 84,3 MB |
| 10.000 | 108,0 MB | 164,5 MB |
wspolos sekitar 5,5 KB per koneksi; Socket.IO sekitar 11 KB heap per koneksi, karena tiap socket membawa objek Engine.IO, Socket.IO, dan room-nya.- File descriptor dibatasi sistem operasi. Dicoba dengan batas 256: 243 koneksi masuk (proses Node sendiri sudah memakai 13), 57 sisanya putus dengan
ECONNRESET, dan server sendiri gagal membuka file:EMFILE: too many open files. Node menaikkan batas lunak (soft limit) sampai batas keras (hard limit) saat start, jadi yang menentukan adalah hard limit. Di Linux dengan systemd, bawaannyaLimitNOFILE=1024:524288.
Jadi 10.000 HP pelanggan masih muat di satu proses, kira-kira 110 sampai 170 MB. Laundry Bu Rini dengan 4 layar dan belasan HP jelas gak butuh apa pun dari pelajaran ini. Proses kedua biasanya ditambah karena alasan lain: CPU satu proses sudah penuh, atau supaya deploy dan mesin mati gak memutus semua orang.
Load balancer dan sticky session
Load balancer (misalnya nginx) menerima semua koneksi dan membaginya ke beberapa proses. Masalahnya ada di transport HTTP long polling milik Socket.IO: satu sesi terdiri dari banyak request HTTP terpisah (handshake, kirim, terima), dan semuanya harus sampai ke proses yang sama.
Dicoba dengan nginx round robin di depan dua server socket.io 4.8.4:
client: connect_error xhr post error (HTTP 400)
server B: connection_error 1 Session ID unknown
Handshake GET /socket.io/?EIO=4&transport=polling sampai di A, jadi sesinya dibuat A. Request berikutnya dibawa nginx ke B, dan B gak kenal sesi itu. Itu jawaban tebakannya: HP Pak Darto gak tersambung.
Dua obatnya, keduanya dicoba dan jalan:
upstream realtime {
ip_hash; # IP yang sama selalu ke proses yang sama
# sticky cookie srv_id; # pilihan lain, ada di nginx open source sejak 1.29.6
server 127.0.0.1:3000;
server 127.0.0.1:3001;
}
// atau: lewati polling, langsung WebSocket. Satu koneksi TCP, jadi gak butuh sticky.
const socket = io('https://laundry.example', { transports: ['websocket'] })
- Sticky session artinya client yang sama selalu diarahkan ke proses yang sama.
ip_hashmemakai tiga oktet pertama IPv4, jadi semua HP di belakang satu NAT operator seluler bisa tertumpuk di satu proses.sticky cookielebih rata. transports: ['websocket']gak butuh sticky, tapi kehilangan cadangan polling untuk jaringan yang memblokir WebSocket.
Proxy: header Upgrade dan batas 60 detik
nginx harus meneruskan header Upgrade dan Connection supaya handshake WebSocket (pelajaran WebSocket) bisa lewat:
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
server {
location /socket.io/ {
proxy_pass http://realtime;
proxy_http_version 1.1; # wajib sebelum nginx 1.29.7; sejak itu sudah bawaan
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_read_timeout 60s; # nilai bawaan
}
}
proxy_read_timeout bawaannya 60 detik: kalau server gak mengirim apa pun selama itu, nginx menutup koneksi. Dicoba dengan nginx 1.27.5: WebSocket yang diam ditutup pada detik 60,01 dengan kode 1006, sedangkan koneksi yang menerima ping dari server tiap 25 detik tetap hidup. Socket.IO sudah mengirim ping tiap 25 detik. Server ws polos harus mengirim ws.ping() sendiri, atau naikkan proxy_read_timeout.
Kabar antar proses: Redis adapter
Room hidup di memori tiap proses, jadi emit dari A gak sampai ke socket di B. Obatnya dari pelajaran Pub/Sub dengan Redis: io.adapter(createAdapter(pub, sub)) dari @socket.io/redis-adapter. Setiap emit dikirim ke Redis, dan setiap proses meneruskannya ke socket miliknya. Sticky session mengurus koneksi, adapter mengurus kabar. Dua-duanya perlu.
Deploy tanpa badai reconnect
Saat proses B dimatikan, semua koneksinya putus bersamaan, dan semua client menyambung lagi. Tanpa acak, 5.000 HP mencoba di milidetik yang sama, ke A. Itu badai reconnect (reconnect storm). Client Socket.IO sudah mengacak jeda pertamanya: 1 detik ± 50%, jadi tersebar di antara 500 dan 1.500 ms. Reconnect buatan sendiri harus memakai backoff dan jitter dari pelajaran WebSocket.
Yang lebih rapi: drain, kosongkan B pelan-pelan sebelum dimatikan.
process.on('SIGTERM', async () => {
httpServer.close() // berhenti menerima koneksi baru; nginx mengarahkan yang baru ke A
const semua = [...io.sockets.sockets.values()]
for (let i = 0; i < semua.length; i += 500) {
semua.slice(i, i + 500).forEach((s) => s.conn.close()) // alasan 'transport close': client menyambung lagi sendiri
await new Promise((r) => setTimeout(r, 1000)) // 500 per detik, bukan semua sekaligus
}
process.exit(0)
})
- Dicoba di belakang nginx: sebelum drain A 5 dan B 5 client, sesudahnya A 10 dan B 0, semua tersambung lagi.
- Jangan pakai
socket.disconnect(true)untuk drain. Alasannya jadiio server disconnect, dan client Socket.IO gak menyambung lagi sendiri. - Matikan proses satu per satu (rolling deploy), jangan semua sekaligus.
Angka untuk laundry
| Laundry Bu Rini | 10.000 HP pelanggan | |
|---|---|---|
| Koneksi | 4 layar + belasan HP | 10.000 |
| Memori | gak terasa | 110 sampai 170 MB, muat di satu proses |
| Butuh proses kedua? | gak | demi deploy tanpa putus dan cadangan kalau mesin mati |
| Yang ikut dibutuhkan | gak ada | sticky (atau websocket saja), Redis adapter, jitter, drain |
⚠️ Jebakan yang sering ketemu
- Polling tanpa sticky.
Session ID unknowndanxhr post error, terutama di jaringan yang jatuh ke polling. - Lupa header Upgrade, atau koneksi diam di belakang proxy. Handshake gagal, atau koneksi diputus tepat di detik 60.
- Restart semua proses bersamaan. Semua client menyerbu proses yang baru hidup. Drain dan restart bergiliran.
- Membagi ke banyak server sebelum mengukur. Satu proses Node menampung puluhan ribu koneksi. Tambah proses kalau angkanya bilang perlu.
TL;DR: Koneksi WebSocket stateful: tiap koneksi dan room-nya menempel di satu proses. Satu proses Node menampung 10.000 koneksi dengan sekitar 110 MB (ws) sampai 170 MB (Socket.IO); batasnya sering file descriptor (EMFILE, ECONNRESET). Di belakang load balancer, polling Socket.IO butuh sticky session (ip_hash atau sticky cookie), kalau gak hasilnya Session ID unknown; transports: ['websocket'] gak butuh sticky. nginx butuh header Upgrade dan Connection, dan menutup koneksi yang diam 60 detik (proxy_read_timeout). Kabar antar proses lewat Redis adapter. Deploy dengan drain dan restart bergiliran, client dengan reconnect berjitter. Laundry kecil gak butuh semua ini.