1. Apa Itu WebSocket?
WebSocket (RFC 6455) adalah protokol komunikasi full-duplex yang beroperasi di atas satu koneksi TCP. Berbeda dengan HTTP yang bersifat request/response, WebSocket memungkinkan client dan server mengirim data secara simultan dan asynchronous setelah koneksi terbentuk.
WebSocket vs HTTP Polling
| Aspek | HTTP Polling | Long Polling | Server-Sent Events | WebSocket |
|---|---|---|---|---|
| Direction | Client β Server | Client β Server | Server β Client | Bidirectional |
| Overhead | ~800 bytes per request | ~800 bytes per poll | HTTP header sekali | 2-6 bytes per frame |
| Latency | Interval-based | Rendah | Rendah | Sangat rendah |
| Real-time | Tidak | Mendekati | Ya (satu arah) | Ya (dua arah) |
| Protocol | HTTP/1.1+ | HTTP/1.1+ | HTTP/1.1+ | ws:// atau wss:// |
| Binary support | Base64 (boros) | Base64 | Base64 | Native binary |
| Use case | Data jarang berubah | Chat ringan | Notifikasi, feed | Chat, gaming, trading |
WebSocket ideal untuk: chat apps (WhatsApp Web), real-time dashboards (monitoring), multiplayer games, collaborative editing (Google Docs), live trading (crypto/forex), dan IoT telemetry. Jika Anda hanya butuh server-to-client, SSE lebih sederhana.
2. WebSocket Handshake
WebSocket menggunakan HTTP Upgrade mechanism untuk memulai koneksi. Client mengirim HTTP request dengan header Upgrade: websocket, server merespons 101 Switching Protocols, dan koneksi berubah dari HTTP ke WebSocket.
Upgrade Handshake Detail
# === Client Request (HTTP Upgrade) === 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 Sec-WebSocket-Protocol: chat, superchat Sec-WebSocket-Extensions: permessage-deflate; client_max_window_bits # === Server Response (101 Switching Protocols) === HTTP/1.1 101 Switching Protocols Upgrade: websocket Connection: Upgrade Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo= Sec-WebSocket-Protocol: chat # === Penjelasan Header === # Sec-WebSocket-Key: random 16-byte nonce, base64-encoded # β Digenerate client baru setiap koneksi # β BUKAN security measure, hanya mencegah cache proxy # Sec-WebSocket-Accept: server menghitung hash dari key client # β SHA1(Key + "258EAFA5-E914-47DA-95CA-5AB40401B2E7") # β "magic string" ini adalah GUID tetap dari RFC 6455 # β Client harus verifikasi hash ini! # Sec-WebSocket-Extensions: permessage-deflate # β Kompresi message (opsional) # β Mengurangi bandwidth 50-80% untuk payload text # Sec-WebSocket-Protocol: subprotocol negotiation # β Client menawarkan beberapa, server memilih satu
Verifikasi Handshake di JavaScript
// Browser API β handshake dilakukan otomatis
const ws = new WebSocket('wss://server.example.com/chat', ['chat', 'superchat']);
ws.onopen = (event) => {
console.log('WebSocket connected!');
// Browser sudah melakukan:
// 1. Generate random Sec-WebSocket-Key
// 2. Kirim HTTP Upgrade request
// 3. Verifikasi Sec-WebSocket-AcAccept dari server
// 4. Switch ke WebSocket mode
ws.send(JSON.stringify({ type: 'join', room: 'general' }));
};
ws.onmessage = (event) => {
console.log('Received:', event.data);
};
ws.onerror = (error) => {
console.error('WebSocket error:', error);
};
ws.onclose = (event) => {
console.log(`Closed: code=${event.code}, reason=${event.reason}`);
};
// Server-side (Node.js dengan 'ws' library):
const WebSocket = require('ws');
const crypto = require('crypto');
const wss = new WebSocket.Server({ port: 8080 });
wss.on('connection', (ws, req) => {
console.log('New connection from:', req.socket.remoteAddress);
// WebSocket library sudah handle handshake otomatis
// Tapi kita bisa akses raw headers:
console.log('Origin:', req.headers['origin']);
console.log('Protocol:', ws.protocol);
ws.on('message', (data) => {
console.log('Received:', data.toString());
ws.send(`Echo: ${data}`);
});
});
3. Frame Format & Data Types
WebSocket menggunakan binary framing yang sangat efisien. Setiap frame memiliki overhead hanya 2-6 bytes (tanpa masking key), jauh lebih kecil dari HTTP header.
WebSocket Frame Structure
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| | (jika payload len=126/127) | | |1|2|3| |K| | | +-+-+-+-+-------+-+-------------+ - - - - - - - - - - - - - - - + | Extended payload length continued, if payload len == 127 | + - - - - - - - - - - - - - - - +-------------------------------+ | |Masking-key, if MASK set to 1 | +-------------------------------+-------------------------------+ | Masking-key (continued) | Payload Data | +-------------------------------- - - - - - - - - - - - - - - - + : Payload Data continued ... : + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + | Payload Data (continued) | +---------------------------------------------------------------+ # Penjelasan Field: # FIN (1 bit) : 1 = frame terakhir dari message # RSV1-3 (3 bit) : Reserved (untuk extensions seperti permessage-deflate) # OPCODE (4 bit) : Tipe frame # MASK (1 bit) : 1 = payload di-mask (clientβserver WAJIB mask) # Payload Length : 0-125 langsung, 126 = 2 byte extended, 127 = 8 byte extended # Opcodes: # 0x0 = Continuation frame # 0x1 = Text frame (UTF-8) # 0x2 = Binary frame # 0x8 = Connection close # 0x9 = Ping # 0xA = Pong
Contoh Frame Encoding
import struct
import os
def encode_ws_frame(payload, opcode=0x1, mask=True):
"""Encode payload menjadi WebSocket frame"""
frame = bytearray()
# Byte pertama: FIN (1) + RSV (000) + Opcode
frame.append(0x80 | opcode) # 0x80 = FIN bit set
# Byte kedua: MASK bit + payload length
length = len(payload)
if length < 126:
frame.append((0x80 if mask else 0) | length)
elif length < 65536:
frame.append((0x80 if mask else 0) | 126)
frame.extend(struct.pack('>H', length))
else:
frame.append((0x80 if mask else 0) | 127)
frame.extend(struct.pack('>Q', length))
# Masking key (4 bytes random)
if mask:
masking_key = os.urandom(4)
frame.extend(masking_key)
# Mask payload
masked = bytearray(len(payload))
for i in range(len(payload)):
masked[i] = payload[i] ^ masking_key[i % 4]
frame.extend(masked)
else:
frame.extend(payload)
return bytes(frame)
# Contoh: Text frame "Hello"
frame = encode_ws_frame(b'Hello')
print(f"Frame size: {len(frame)} bytes") # 11 bytes (2 header + 4 mask + 5 payload)
print(f"Overhead: {len(frame) - len(b'Hello')} bytes") # 6 bytes overhead
# Contoh: Binary frame dengan data 1000 bytes
import json
data = json.dumps({"temp": 25.3, "humidity": 62}).encode()
frame = encode_ws_frame(data, opcode=0x2)
print(f"Binary frame: {len(frame)} bytes for {len(data)} bytes payload")
4. Ping/Pong & Heartbeat
WebSocket mendukung mekanisme Ping/Pong untuk mendeteksi koneksi yang mati (liveness check) dan menjaga koneksi tetap hidup melalui proxy/firewall yang mungkin akan menutup idle connections.
Mekanisme Ping/Pong
# Ping/Pong Flow: # 1. Bisa dikirim oleh KEDUA belah pihak (client dan server) # 2. Pong WAJIB dikirim sebagai respons terhadap Ping # 3. Ping bisa membawa payload (application data) # 4. Pong HARUS meng-copy payload dari Ping # Contoh flow: Client Server | | |--- Ping (frame 0x9) -------->| | payload: "keepalive" | | | |<-- Pong (frame 0xA) ---------| | payload: "keepalive" | β HARUS sama dengan Ping payload | | | ... connection healthy ...| | | |--- Ping ---------------------->| | | | (timeout, tidak ada Pong) | | β Koneksi dianggap MATI | | β Trigger close/onerror | # Best Practice: # - Ping interval: 30 detik (normal), 15 detik (high-reliability) # - Pong timeout: 10 detik (jika tidak ada Pong dalam 10s β close) # - Jangan terlalu sering (boros bandwidth) atau terlalu jarang (lambat deteksi)
Implementasi Heartbeat di Node.js
const WebSocket = require('ws');
class HeartbeatWebSocket {
constructor(url, options = {}) {
this.url = url;
this.pingInterval = options.pingInterval || 30000; // 30 detik
this.pongTimeout = options.pongTimeout || 10000; // 10 detik
this.isAlive = false;
this.pingTimer = null;
this.pongTimer = null;
this.connect();
}
connect() {
this.ws = new WebSocket(this.url);
this.ws.on('open', () => {
console.log('Connected');
this.isAlive = true;
this.startHeartbeat();
});
this.ws.on('pong', (data) => {
// Pong diterima β koneksi hidup
this.isAlive = true;
clearTimeout(this.pongTimer);
console.log(`Pong received: ${data.toString()}`);
});
this.ws.on('close', () => {
console.log('Disconnected');
this.stopHeartbeat();
// Reconnect setelah 3 detik
setTimeout(() => this.connect(), 3000);
});
}
startHeartbeat() {
this.pingTimer = setInterval(() => {
if (!this.isAlive) {
console.log('No pong received, terminating...');
return this.ws.terminate();
}
this.isAlive = false;
this.ws.ping('heartbeat');
// Set timeout untuk pong
this.pongTimer = setTimeout(() => {
if (!this.isAlive) {
console.log('Pong timeout, closing...');
this.ws.close(1000, 'Pong timeout');
}
}, this.pongTimeout);
}, this.pingInterval);
}
stopHeartbeat() {
clearInterval(this.pingTimer);
clearTimeout(this.pongTimer);
}
}
// Server-side heartbeat
const wss = new WebSocket.Server({ port: 8080 });
wss.on('connection', (ws) => {
ws.isAlive = true;
ws.on('pong', () => {
ws.isAlive = true;
});
});
// Periodic check β setiap 30 detik
const heartbeatInterval = setInterval(() => {
wss.clients.forEach((ws) => {
if (!ws.isAlive) return ws.terminate();
ws.isAlive = false;
ws.ping();
});
}, 30000);
wss.on('close', () => clearInterval(heartbeatInterval));
5. Close Handshake
WebSocket memiliki mekanisme graceful close yang memungkinkan kedua belah pihak menutup koneksi dengan terhormat, termasuk mengirim alasan penutupan.
Close Frame Format & Status Codes
# Close Frame Structure: # OPCODE: 0x8 # Payload: 2-byte status code (big-endian) + optional UTF-8 reason string # Close Handshake: # 1. Peer A mengirim Close frame # 2. Peer B menerima Close frame # 3. Peer B mengirim Close frame balasan (echo status code) # 4. TCP connection ditutup Client Server | | |--- Close (1000, "Normal") -->| | | |<-- Close (1000) --------------| | | | TCP FIN βββββββββββββββββββ> | | <βββββββββββββββββββββββββ FIN ACK # Status Codes: # 1000 β Normal closure # 1001 β Going away (server shutdown, browser navigate away) # 1002 β Protocol error # 1003 β Unsupported data type (text vs binary mismatch) # 1005 β No status received (internal, tidak dikirim di frame) # 1006 β Abnormal closure (internal, tidak dikirim di frame) # 1007 β Invalid frame payload data (misal: UTF-8 encoding error) # 1008 β Policy violation # 1009 β Message too big # 1010 β Mandatory extension not negotiated # 1011 β Internal server error # 1012 β Service restart # 1013 β Try again later # 1014 β Bad gateway # 3000-3999 β Registered extensions # 4000-4999 β Application-defined
6. Scaling & Load Balancing
Scaling WebSocket server adalah tantangan tersendiri karena sifat stateful dari koneksi. Setiap client terikat ke satu server instance, berbeda dengan HTTP stateless yang bisa di-route ke mana saja.
Tantangan Scaling WebSocket
| Tantangan | Penjelasan | Solusi |
|---|---|---|
| Sticky Sessions | Client harus terhubung ke server yang sama | Sticky load balancing berdasarkan IP/cookie |
| State Management | State harus di-share antar server | Redis Pub/Sub sebagai message bus |
| Connection Memory | Setiap koneksi butuh RAM ~20-50KB | Optimasi buffer, horizontal scaling |
| Health Check | Load balancer perlu cek koneksi | Custom health endpoint, ping/pong |
| Graceful Shutdown | Server update tanpa memutus koneksi | Drain connections sebelum restart |
Architecture: Multi-Server dengan Redis Pub/Sub
# === Arsitektur Scaling WebSocket ===
# Client A (terhubung ke Server 1) mengirim message ke Client C (terhubung ke Server 2)
ββββββββββββββββ ββββββββββββββββ
β Client A β β Client B β
β (Server 1) β β (Server 1) β
ββββββββ¬ββββββββ ββββββββ¬ββββββββ
β β
βΌ βΌ
βββββββββββββββββββββββββββββββββββ
β Load Balancer β
β (Sticky by IP / Cookie) β
β Nginx / HAProxy / AWS ALB β
ββββββ¬βββββββββββββ¬ββββββββββββ¬ββββ
βΌ βΌ βΌ
βββββββββββ βββββββββββ βββββββββββ
β Server 1β β Server 2β β Server 3β
β Node.jsβ β Node.jsβ β Node.jsβ
ββββββ¬βββββ ββββββ¬βββββ ββββββ¬βββββ
β β β
βΌ βΌ βΌ
βββββββββββββββββββββββββββββββββββ
β Redis Pub/Sub β
β Channel: "chat:room-1" β
β Channel: "notifications" β
β Channel: "broadcast" β
βββββββββββββββββββββββββββββββββββ
# Flow:
# 1. Client A β Server 1: "Hello everyone!"
# 2. Server 1 β Redis PUBLISH "chat:room-1" {message}
# 3. Server 1, 2, 3 semua SUBSCRIBE "chat:room-1"
# 4. Server 1 β kirim ke Client A, B (yang terhubung ke Server 1)
# 5. Server 2 β kirim ke Client C (yang terhubung ke Server 2)
# 6. Server 3 β kirim ke Client D (yang terhubung ke Server 3)
Implementasi Multi-Server
const WebSocket = require('ws');
const Redis = require('ioredis');
const http = require('http');
// Setup Redis
const pub = new Redis({ host: 'redis-server', port: 6379 });
const sub = new Redis({ host: 'redis-server', port: 6379 });
// Setup HTTP + WebSocket server
const server = http.createServer();
const wss = new WebSocket.Server({ server });
// Track clients per room
const rooms = new Map();
wss.on('connection', (ws, req) => {
const clientId = generateId();
ws.clientId = clientId;
ws.rooms = new Set();
ws.on('message', (data) => {
const msg = JSON.parse(data);
switch (msg.type) {
case 'join':
joinRoom(ws, msg.room);
break;
case 'chat':
broadcastToRoom(msg.room, {
type: 'chat',
from: clientId,
message: msg.message,
timestamp: Date.now()
}, ws); // exclude sender
break;
}
});
ws.on('close', () => {
// Leave all rooms
ws.rooms.forEach(room => leaveRoom(ws, room));
});
});
function joinRoom(ws, room) {
ws.rooms.add(room);
if (!rooms.has(room)) {
rooms.set(room, new Set());
// Subscribe ke Redis channel untuk room ini
sub.subscribe(`room:${room}`);
}
rooms.get(room).add(ws);
// Notify semua di room
broadcastToRoom(room, {
type: 'join',
user: ws.clientId,
room: room
});
}
function broadcastToRoom(room, message, exclude = null) {
const localClients = rooms.get(room) || new Set();
// Kirim ke client lokal
const payload = JSON.stringify(message);
localClients.forEach(client => {
if (client !== exclude && client.readyState === WebSocket.OPEN) {
client.send(payload);
}
});
// Publish ke Redis agar server lain juga mengirim
pub.publish(`room:${room}`, JSON.stringify({
...message,
_fromServer: SERVER_ID
}));
}
// Terima message dari server lain via Redis
sub.on('message', (channel, data) => {
const message = JSON.parse(data);
const room = channel.replace('room:', '');
// Skip jika dari server ini sendiri
if (message._fromServer === SERVER_ID) return;
// Kirim ke client lokal
const clients = rooms.get(room) || new Set();
clients.forEach(client => {
if (client.readyState === WebSocket.OPEN) {
client.send(JSON.stringify(message));
}
});
});
const SERVER_ID = process.env.SERVER_ID || 'server-1';
server.listen(8080, () => console.log(`WS Server ${SERVER_ID} on :8080`));
Di Nginx, pastikan menambahkan header Upgrade dan Connection di proxy_pass. Tanpa ini, WebSocket handshake akan gagal. Tambahkan juga proxy_read_timeout yang cukup panjang agar Nginx tidak menutup koneksi idle.
# Nginx configuration untuk WebSocket load balancing
upstream websocket_backend {
ip_hash; # Sticky session berdasarkan IP
server 127.0.0.1:8081;
server 127.0.0.1:8082;
server 127.0.0.1:8083;
}
server {
listen 443 ssl;
server_name ws.example.com;
location /ws {
proxy_pass http://websocket_backend;
proxy_http_version 1.1;
# Header untuk WebSocket upgrade
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
# Timeout β 1 jam untuk WebSocket
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
# Preserve client info
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# Buffering off untuk real-time
proxy_buffering off;
}
}
7. Implementasi dengan Node.js & Socket.IO
Socket.IO adalah library populer yang built di atas WebSocket dengan fitur tambahan: automatic reconnection, rooms, namespaces, fallback ke HTTP polling, dan binary support.
// === Server (Express + Socket.IO) ===
const express = require('express');
const { createServer } = require('http');
const { Server } = require('socket.io');
const app = express();
const httpServer = createServer(app);
const io = new Server(httpServer, {
cors: { origin: '*' },
pingInterval: 25000,
pingTimeout: 20000,
maxHttpBufferSize: 1e6 // 1MB
});
// Namespace: /chat
const chatNs = io.of('/chat');
chatNs.use((socket, next) => {
// Middleware: authentication
const token = socket.handshake.auth.token;
if (verifyToken(token)) {
socket.userId = decodeToken(token).id;
next();
} else {
next(new Error('Authentication failed'));
}
});
chatNs.on('connection', (socket) => {
console.log(`User ${socket.userId} connected`);
// Join room
socket.on('join-room', (roomId) => {
socket.join(roomId);
socket.to(roomId).emit('user-joined', {
userId: socket.userId,
timestamp: Date.now()
});
});
// Chat message
socket.on('chat-message', (data) => {
const { room, message } = data;
chatNs.to(room).emit('new-message', {
from: socket.userId,
message: message,
timestamp: Date.now()
});
});
// Typing indicator
socket.on('typing', (room) => {
socket.to(room).emit('user-typing', { userId: socket.userId });
});
// Disconnect
socket.on('disconnect', (reason) => {
console.log(`User ${socket.userId} disconnected: ${reason}`);
});
});
httpServer.listen(3000);
// === Client (Browser) ===
import { io } from 'socket.io-client';
const socket = io('https://server.example.com/chat', {
auth: { token: 'jwt-token-here' },
reconnection: true,
reconnectionDelay: 1000,
reconnectionAttempts: 10,
transports: ['websocket', 'polling']
});
socket.on('connect', () => console.log('Connected!'));
socket.on('new-message', (data) => console.log('Message:', data));
socket.emit('join-room', 'room-general');
socket.emit('chat-message', { room: 'room-general', message: 'Halo semua!' });