Scaling WebSocket (Horizontal) — Realtime

Scaling WebSocket Single server handles ~10K-100K WebSocket connections. Butuh lebih? Scale horizontally — multiple server instances dengan shared state…

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

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'] })

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)
})

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

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.

Yang akan kamu pelajari