karawaci.kode

2026-06-18 · 9 min

WebSocket vs SSE vs Long Polling — pilih yang tepat untuk real-time app

Satu tahun lalu saya debug production incident di fintech startup: user complain harga saham di dashboard tidak update real-time padahal backend WebSocket sudah berjalan. Root cause: load balancer AWS ALB tidak configured sticky session, koneksi WebSocket di-drop setiap 60 detik karena di-route ke server berbeda yang tidak punya state koneksi tersebut.

Fix-nya sederhana, tapi cost-nya nyata — 3 jam downtime partial, 400+ ticket CS, dan re-architecture yang harusnya dilakukan dari awal kalau pilihan transport protocol lebih dipikirkan.

Tulisan ini tentang keputusan teknis yang kelihatan sepele tapi punya konsekuensi production nyata: pilih WebSocket, SSE, atau Long Polling.

Tiga pola real-time: overview cepat

Sebelum deep-dive, perbandingan high-level:

WebSocketSSELong Polling
ProtocolFull-duplex TCPHalf-duplex HTTP (server→client)HTTP request-response loop
Latency1–5ms5–30ms100–500ms
Browser supportUniversalUniversal (no IE11)Universal
Load balancerSticky session wajibStateless OKStateless OK
ComplexityTinggiRendahSedang
Server resource per conn~6KB RAM (ws frame)~3KB RAM (HTTP stream)~25KB RAM (thread/conn hold)
Debug easeSusah (binary frame)Mudah (plain text)Mudah (HTTP biasa)

Tiga angka yang paling sering diabaikan: latency, load balancer requirement, dan server resource per connection. Ketiganya menentukan apakah pilihan Anda scale atau tidak.

WebSocket: kapan worth it

WebSocket adalah koneksi TCP persistent full-duplex. Setelah handshake HTTP upgrade, kedua sisi bisa kirim frame kapanpun tanpa request-response cycle.

Use case yang genuinely worth the complexity:

  • Multiplayer game: player position, state, event harus sync < 16ms (60fps). WebSocket satu-satunya pilihan.
  • Collaborative editing (Figma-style, Google Docs): setiap keystroke dari satu user harus propagate ke semua collaborator dengan operational transformation atau CRDT. Bidireksinya krusial.
  • Live trading UI: order book tick, price update, dan order submission harus pakai satu koneksi. SSE hanya cover separuh (price update), sisanya (order submit) masih butuh channel balik.
  • Live chat dengan typing indicator: bukan hanya pesan, tapi “user X sedang mengetik…” butuh client-to-server push yang frequent.

Minimal Node.js setup dengan library ws:

// server.js
const { WebSocketServer } = require('ws');
const http = require('http');

const server = http.createServer();
const wss = new WebSocketServer({ server });

const clients = new Map(); // clientId → ws

wss.on('connection', (ws, req) => {
  const clientId = req.headers['x-client-id'] || crypto.randomUUID();
  clients.set(clientId, ws);

  console.log(`Client connected: ${clientId}, total: ${clients.size}`);

  ws.on('message', (data) => {
    const msg = JSON.parse(data);

    // Broadcast ke semua client kecuali pengirim
    for (const [id, client] of clients) {
      if (id !== clientId && client.readyState === ws.OPEN) {
        client.send(JSON.stringify({ from: clientId, ...msg }));
      }
    }
  });

  ws.on('close', () => {
    clients.delete(clientId);
    console.log(`Client disconnected: ${clientId}, total: ${clients.size}`);
  });

  // Heartbeat — deteksi zombie connection
  ws.isAlive = true;
  ws.on('pong', () => { ws.isAlive = true; });
});

// Ping semua client tiap 30 detik, terminate zombie
const heartbeat = setInterval(() => {
  for (const [id, ws] of clients) {
    if (!ws.isAlive) {
      ws.terminate();
      clients.delete(id);
      return;
    }
    ws.isAlive = false;
    ws.ping();
  }
}, 30_000);

wss.on('close', () => clearInterval(heartbeat));

server.listen(3000, () => console.log('WS server port 3000'));

Gotcha production yang wajib tahu:

  1. Sticky session load balancer wajib. AWS ALB: aktifkan “Stickiness” di target group (duration-based cookie). Nginx: ip_hash atau sticky module. Tanpa ini, WebSocket di-drop setiap server switch.

  2. Reconnect logic di client. Browser tidak auto-reconnect WebSocket. Implementasi backoff exponential sendiri atau pakai library seperti reconnecting-websocket.

  3. Connection state management di server. Kalau server restart, semua koneksi lost. Butuh state external (Redis pub/sub) kalau horizontal scale. Single node → Map biasa cukup.

  4. Memory leak dari zombie connection. Koneksi yang network drop tanpa FIN packet tidak terdeteksi tanpa heartbeat. Selalu implementasi ping/pong mechanism (lihat kode di atas).

SSE (Server-Sent Events): underrated choice

SSE adalah HTTP response yang tidak pernah selesai. Server kirim data dalam format sederhana, browser handle reconnect otomatis. Mayoritas engineer skip SSE dan langsung ke WebSocket — keputusan yang sering berlebihan.

Use case ideal:

  • Live dashboard: harga, metric, status. Server push update, client tidak kirim apa-apa kecuali filter preference (via query param atau POST awal).
  • Notification feed: “order kamu sudah dikirim”, “ada pesan baru”. One-directional, frekuensi rendah-sedang.
  • Progress update: upload processing, batch job, report generation. Client perlu tahu progress tapi tidak perlu reply.
  • Live log streaming: kubectl logs -f style di web UI.

Minimal Express + SSE:

// server.js
const express = require('express');
const app = express();

// Simulasi event emitter untuk data source
const EventEmitter = require('events');
const dataEmitter = new EventEmitter();

// Endpoint SSE
app.get('/events', (req, res) => {
  // Header wajib SSE
  res.setHeader('Content-Type', 'text/event-stream');
  res.setHeader('Cache-Control', 'no-cache');
  res.setHeader('Connection', 'keep-alive');
  res.setHeader('Access-Control-Allow-Origin', '*'); // CORS

  // Kirim comment sebagai initial heartbeat
  res.write(': connected\n\n');

  // Handler event baru
  const onData = (data) => {
    res.write(`id: ${data.id}\n`);
    res.write(`event: ${data.type}\n`);
    res.write(`data: ${JSON.stringify(data.payload)}\n\n`);
  };

  dataEmitter.on('update', onData);

  // Heartbeat tiap 15 detik — cegah proxy/LB timeout
  const heartbeat = setInterval(() => {
    res.write(': heartbeat\n\n');
  }, 15_000);

  // Cleanup saat client disconnect
  req.on('close', () => {
    dataEmitter.off('update', onData);
    clearInterval(heartbeat);
    console.log('SSE client disconnected');
  });
});

// Simulasi data update (dari DB, queue, dll)
setInterval(() => {
  dataEmitter.emit('update', {
    id: Date.now(),
    type: 'price-update',
    payload: { symbol: 'BBCA', price: 9000 + Math.random() * 100 | 0 }
  });
}, 1000);

app.listen(3001, () => console.log('SSE server port 3001'));

Client-side: browser native, zero library:

const evtSource = new EventSource('/events');

evtSource.addEventListener('price-update', (e) => {
  const data = JSON.parse(e.data);
  document.getElementById('price').textContent = data.price;
});

evtSource.onerror = (err) => {
  console.error('SSE error, browser will auto-reconnect:', err);
  // Browser otomatis reconnect dengan Last-Event-ID header
  // Tidak perlu manual reconnect logic
};

Kenapa SSE sering lebih baik dari WebSocket untuk server-push:

  1. Native reconnect: browser kirim Last-Event-ID header saat reconnect. Server bisa replay missed event. WebSocket tidak punya mekanisme ini by default.
  2. Stateless load balancer: tidak perlu sticky session. Any server bisa handle SSE client. Scale horizontal lebih mudah.
  3. Works through nginx tanpa config khusus: nginx proxy_pass standard cukup, asalkan proxy_buffering off untuk disable response buffering.
  4. Debug mudah: format plain text, kelihatan di browser DevTools Network tab seperti HTTP response biasa.

Nginx config minimal untuk SSE:

location /events {
    proxy_pass http://backend;
    proxy_buffering off;          # Wajib — disable response buffer
    proxy_cache off;
    proxy_set_header Connection '';
    proxy_http_version 1.1;
    chunked_transfer_encoding on;
}

Long Polling: kapan masih masuk akal

Long Polling adalah client kirim HTTP request, server tahan koneksi sampai ada data baru atau timeout (biasanya 30–60 detik), client langsung kirim request baru setelah response diterima.

Ini bukan teknologi “jadul yang harus diganti” — ini pilihan valid untuk konteks spesifik.

Use case yang masih masuk akal di 2026:

  • Legacy browser support wajib (enterprise intranet dengan IE11, browser korporat lama): SSE tidak support, WebSocket kadang di-block.
  • Behind strict corporate firewall: banyak firewall korporat block WebSocket upgrade atau koneksi persistent. HTTP port 80/443 dengan request-response biasa: lolos.
  • Simple notification polling dengan frekuensi rendah: kalau update hanya datang tiap 30+ detik dan concurrent user < 200, overhead Long Polling masih acceptable.
  • Third-party integration yang tidak support WebSocket/SSE: beberapa payment gateway callback, webhook internal yang butuh polling manual.

Trade-off yang harus diterima:

  • 2–3x lebih banyak HTTP overhead vs SSE: setiap “cycle” ada request header ~800 bytes. Untuk 1000 concurrent, ini 800KB/s overhead pure dari header.
  • Latency 100–500ms inherent: ada round-trip time tiap cycle, bukan koneksi persistent.
  • Server hold connection open: 1000 concurrent = 1000 thread/connection di-hold. Resource per connection lebih besar dari SSE.

Kapan JANGAN pakai Long Polling:

  • Update frekuensi > 1 per detik (overhead HTTP jadi dominant cost).
  • 500 concurrent user (connection hold overhead signifikan, scaling susah).

  • Butuh latency < 100ms (tidak bisa karena HTTP round-trip).

Decision framework

Tiga pertanyaan, jawab berurutan:

1. Perlu client → server push secara real-time?
   (bukan cuma submit form/REST call, tapi streaming/event dari browser ke server)

   ├── Ya  → WebSocket.
   │          (game, collaborative edit, live chat dengan typing indicator)

   └── Tidak → lanjut ke pertanyaan 2.

2. Butuh latency < 50ms ATAU concurrent active connections > 500?

   ├── Ya  → WebSocket.
   │          (trading UI, real-time sensor, live video metadata)

   └── Tidak → lanjut ke pertanyaan 3.

3. Harus support legacy browser ATAU ada strict firewall yang block persistent connection?

   ├── Ya  → Long Polling.
   │          (enterprise intranet, corporate network, IE-era requirement)

   └── Tidak → SSE.
               (dashboard, notification, progress — default choice untuk server-push)

Mayoritas use case “real-time” yang engineer langsung reach for WebSocket sebenarnya masuk di SSE: dashboard metric, notifikasi, live feed, order status update. WebSocket diperlukan lebih jarang dari yang dikira.

Production gotcha

WebSocket: sticky session di Nginx dan ALB

Nginx:

upstream ws_backend {
    ip_hash;  # Sticky berdasar IP — simple tapi tidak ideal behind NAT
    server backend1:3000;
    server backend2:3000;
}

server {
    location /ws {
        proxy_pass http://ws_backend;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
        proxy_read_timeout 3600s;  # Jangan default 60s, WS bisa idle lama
    }
}

AWS ALB: di Target Group settings, aktifkan “Stickiness” → “Load balancer generated cookie” dengan duration 1 hari. Tanpa ini, ALB bisa route reconnect ke server berbeda dan koneksi WebSocket gagal establish ulang.

Solusi lebih proper untuk horizontal scale: jangan simpan state di memory server. Gunakan Redis pub/sub sebagai message broker — setiap server subscribe ke channel, forward ke client yang terhubung di server tersebut. Ini eliminasi kebutuhan sticky session.

SSE: CORS dan EventSource header limitation

EventSource browser tidak support custom header — tidak bisa kirim Authorization: Bearer <token> seperti biasa.

Workaround yang common:

  1. Token di query param: /events?token=xxx. Aman kalau HTTPS, tapi token masuk server log. Mitigasi: token short-lived (< 5 menit) atau one-time-use.
  2. Cookie-based auth: set cookie dulu via endpoint terpisah, SSE otomatis kirim cookie. Perlu withCredentials di EventSource dan CORS config credentials: true.
// Client dengan cookie auth
const evtSource = new EventSource('/events', { withCredentials: true });
// Server Express dengan CORS untuk SSE
app.use(cors({
  origin: 'https://app.example.com',
  credentials: true,             // Wajib untuk withCredentials
}));

Jangan lupa Access-Control-Allow-Origin tidak bisa * kalau credentials: true.

Long Polling: timeout management

Server harus return sebelum proxy/LB timeout. Standar yang work:

  • Server timeout: 30 detik (hold koneksi maksimal 30 detik, lalu return empty response).
  • Proxy timeout: 45 detik (lebih besar dari server timeout, beri buffer).
  • Client retry interval: immediate setelah dapat response (dengan minimal 1 detik delay untuk avoid tight loop saat error).
// Long polling endpoint — Express
app.get('/poll', async (req, res) => {
  const lastId = req.query.lastId || '0';
  const timeout = 30_000; // 30 detik server-side timeout

  // Coba ambil data baru dari DB/queue
  const deadline = Date.now() + timeout;

  while (Date.now() < deadline) {
    const data = await fetchNewData(lastId); // non-blocking query

    if (data.length > 0) {
      return res.json({ events: data, lastId: data.at(-1).id });
    }

    // Tidak ada data baru, tunggu 1 detik sebelum retry
    await new Promise(r => setTimeout(r, 1000));
  }

  // Timeout — return empty, client langsung poll lagi
  res.json({ events: [], lastId });
});

Pastikan fetchNewData benar-benar non-blocking. Jangan pakai busy-wait. Gunakan database polling dengan index proper pada timestamp/id column.

Verdict

WebSocket bukan default yang tepat untuk semua hal “real-time”. Pemilihan protocol adalah keputusan arsitektur dengan konsekuensi nyata di load balancer config, operational complexity, dan cost scaling.

Rekomendasi praktis:

  • Default untuk server-push: SSE. Lebih simpel, stateless, native reconnect, debug mudah.
  • Pakai WebSocket hanya kalau butuh bidirectionality atau sub-50ms latency dengan volume tinggi.
  • Long Polling sebagai fallback terakhir untuk constraint legacy/firewall, bukan sebagai default.

Banyak incident production real-time app bukan karena pilihan language atau framework, tapi karena mismatch antara transport protocol dan infrastruktur di depannya. Pahami constraint infrastruktur Anda — load balancer type, proxy config, firewall rule — sebelum commit ke arsitektur.

Lihat juga Observability Stack Grafana + Loki + Tempo untuk konteks monitoring app real-time di production.

Ditulis oleh Reza Pradipta