# Membangun Real-time Microservices dengan Node.js dan WebSockets

> Diterbitkan oleh Literally Web ID pada 21 Oct 2026
> Sumber: https://literally.web.id/articles/membangun-real-time-microservices-dengan-nodejs-dan-websockets

---

## 1. Titik Awal Masalah: Mengapa Arsitektur Lama Sering Ambruk di Produksi

Ingatkah Anda malam ketika notifikasiPagerDuty berdering tanpa henti, sementara grafik CPU di dashboard pemantauan melesat vertikal menembus garis merah? Saya mengalaminya tiga tahun lalu, tepat saat fitur *live bidding* produk kami diluncurkan ke publik. Permintaan melonjak sepuluh kali lipat dalam hitungan menit, dan apa yang terjadi? Server Node.js kami megap-megap, memori membengkak hingga proses *Garmin GC* menyerah, dan ribuan koneksi WebSocket terputus serentak. Masalahnya klise namun mematikan: kami memperlakukan koneksi *stateful* WebSocket seolah-olah mereka adalah request HTTP REST yang stateless dan ramah *load balancer*.

Di atas kertas, arsitektur monolitik tunggal tampak menawan. Namun, begitu pengguna menembus angka ratusan ribu, instans tunggal tidak akan pernah cukup. Anda mulai melakukan *horizontal scaling*, menaruh Nginx di depan, lalu memecah aplikasi menjadi beberapa *microservices*. Di sinilah mimpi buruk dimulai. Ketika Client A terhubung ke Pod Node.js #1 dan Client B terhubung ke Pod Node.js #2, pesan yang dikirimkan Client A tidak akan pernah sampai ke Client B karena kedua pod tersebut hidup di dunia terisolasi yang tidak saling menyapa. Anda butuh jembatan komunikasi lintas server yang cepat, andal, dan tidak membuat latensi melonjak drastis. Dari sinilah kita mulai merombak total cara pandang kita terhadap arsitektur real-time.

## 2. Di Balik Layar: Bagaimana Sistem Ini Bekerja Tanpa Sihir

Mari kita bedah secara mekanis apa yang sebenarnya terjadi di bawah tenda ketika kita membangun sistem real-time terdistribusi. Bayangkan WebSocket sebagai pipa ledeng terbuka yang menjaga aliran air (data) tetap mengalir dua arah tanpa perlu membuka keran baru setiap kali air ingin dikirim. Protokol ini melakukan *handshake* sekali di awal melalui HTTP, lalu mengubah jalurnya menjadi protokol TCP murni yang ringan.

> [!TIP]
> Jangan pernah meremehkan overhead dari proses *serialization*. Mengirimkan objek JavaScript mentah melalui WebSocket tanpa skema yang jelas adalah jalan pintas menuju pemborosan bandwidth.

Namun, ketika arsitektur Anda berkembang menjadi kluster microservices, pipa ledeng tunggal tadi pecah menjadi jaringan pipa yang terpisah-pisah di setiap kontainer Docker Anda. Untuk menyatukannya kembali tanpa mengorbankan kecepatan, kita memerlukan sistem perantara pesan (*message broker*) yang bertindak sebagai saraf pusat kluster. Di sinilah Redis Pub/Sub masuk sebagai pahlawan tanpa tanda jasa. Setiap kali sebuah pod Node.js menerima pesan dari klien WebSocket-nya, pod tersebut tidak langsung menyebarkannya ke semua klien lokal, melainkan mempublikasikannya (*publish*) ke sebuah *channel* Redis. Redis, dengan kecepatan *in-memory*-nya yang luar biasa, akan segera menyiarkan (*broadcast*) pesan tersebut ke seluruh pod Node.js lain yang sedang mendengarkan (*subscribe*). Hasilnya? Klien yang menempel di Pod #2 dapat menerima pesan dari klien di Pod #1 dalam hitungan milidetik. Sederhana, elegan, dan terbukti tahan banting menghadapi terjangan traffic jutaan pengguna.

## 3. Pilihan Pendekatan & Kompromi yang Harus Diambil

Sebelum kita menulis baris kode pertama, mari kita letakkan kartu di atas meja dan jujur mengenai trade-off arsitektural yang harus dihadapi. Dalam dunia *real-time systems*, tidak ada peluru perak. Pilihan pertama yang sering diperdebatkan adalah antara **Redis Pub/Sub** dan **Apache Kafka**. Kafka adalah raksasa yang luar biasa untuk *event streaming* jangka panjang dan ketahanan data yang masif, namun ia membawa kompleksitas operasional yang luar biasa berat untuk sekadar meneruskan pesan WebSocket yang sifatnya *ephemeral* (sekilas lalu). Sebaliknya, Redis jauh lebih ringan, sangat cepat, dan sangat pas untuk kebutuhan Pub/Sub murni, meskipun ia tidak menyimpan riwayat pesan secara permanen.

> [!WARNING]
> Redis Pub/Sub bersifat *fire-and-forget*. Jika sebuah *subscriber* sedang terputus saat pesan dipublikasikan, pesan tersebut akan hilang selamanya. Jika aplikasi Anda menuntut jaminan pengiriman 100% tanpa celah, Anda harus mengombinasikannya dengan lapisan penyimpanan persisten seperti Redis Streams atau database transaksional.

Kompromi kedua menyangkut manajemen *state* koneksi. Apakah kita harus menyimpan pemetaan `userId` ke `socketId` di dalam memori lokal proses Node.js, atau menyimpannya secara terpusat di Redis? Menyimpan di memori lokal memberikan kecepatan akses instan nol-latensi, namun membuat proses *graceful shutdown* menjadi mimpi buruk karena Anda akan kehilangan jejak koneksi saat deployment bergulir. Menyimpan di Redis memang menambah sedikit latensi jaringan, tetapi memberikan fleksibilitas penuh bagi kluster untuk melakukan *rolling update* tanpa membuat pengguna menyadari bahwa mereka sedang dipindahkan ke pod yang baru. Mari kita lihat bagaimana kita mengeksekusi pendekatan hibrida yang paling masuk akal ini secara teknis.

## 4. Meracik Lingkungan: Persiapan & Prasyarat Lapangan

Sebelum tangan kita kotor dengan kode JavaScript, pastikan fondasi infrastruktur di mesin lokal atau server staging Anda sudah berdiri kokoh. Kita akan mensimulasikan lingkungan produksi mini menggunakan Docker Compose, yang merangkai Node.js, Redis, dan Nginx sebagai *reverse proxy*.

Buat direktori proyek baru, lalu siapkan berkas `docker-compose.yml` dengan konfigurasi yang siap menanggung beban berat:

```yaml
version: '3.8'

services:
  redis:
    image: redis:7.0-alpine
    container_name: realtime_redis
    ports:
      - "6379:6379"
    command: redis-server --save 60 1 --loglevel warning
    networks:
      - app-net

  app-node-1:
    build: .
    container_name: realtime_node_1
    environment:
      - PORT=3000
      - REDIS_HOST=redis
      - INSTANCE_ID=node-worker-1
    ports:
      - "3001:3000"
    depends_on:
      - redis
    networks:
      - app-net

  app-node-2:
    build: .
    container_name: realtime_node_2
    environment:
      - PORT=3000
      - REDIS_HOST=redis
      - INSTANCE_ID=node-worker-2
    ports:
      - "3002:3000"
    depends_on:
      - redis
    networks:
      - app-net

  nginx:
    image: nginx:alpine
    container_name: realtime_nginx
    volumes:
      - ./nginx.conf:/etc/nginx/nginx.conf:ro
    ports:
      - "80:80"
    depends_on:
      - app-node-1
      - app-node-2
    networks:
      - app-net

networks:
  app-net:
    driver: bridge
```

Konfigurasi Docker di atas memastikan kita memiliki dua instans aplikasi Node.js yang siap diuji di balik *load balancer*. Sekarang, mari kita siapkan perisai Nginx di `nginx.conf` agar ia memahami bagaimana cara meneruskan lalu lintas WebSocket yang rewel tanpa memutus sambungan di tengah jalan:

```nginx
events { worker_connections 1024; }

http {
    upstream websocket_cluster {
        least_conn;
        server app-node-1:3000;
        server app-node-2:3000;
    }

    server {
        listen 80;

        location / {
            proxy_pass http://websocket_cluster;
            proxy_http_version 1.1;
            proxy_set_header Upgrade $http_upgrade;
            proxy_set_header Connection "upgrade";
            proxy_set_header Host $host;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            
            # Timeout krusial agar koneksi idle tidak diputus sepihak oleh Nginx
            proxy_read_timeout 3600s;
            proxy_send_timeout 3600s;
        }
    }
}
```

Dengan fondasi infrastruktur ini, Nginx akan secara cerdas membagi koneksi menggunakan algoritma *least connections* ke kluster Node.js kita, sementara Redis berdiri sebagai penjaga gerbang komunikasi antar-pod yang sangat tangguh.

## 5. Langkah Implementasi: Membangun Solusi yang Tangguh

Saatnya merangkai kode aplikasi Node.js. Kita akan menggunakan pustaka `ws` untuk menangani WebSocket murni dan `ioredis` sebagai klien Redis yang terkenal andal serta mendukung *cluster mode* secara native.

Inisialisasi proyek Node.js Anda, pasang dependensi yang diperlukan (`npm install ws ioredis dotenv`), lalu buat berkas utama `server.js`. Mari kita tulis kode dengan arsitektur modular yang memisahkan logika server WebSocket dari mekanisme *broker* Redis:

```javascript
const { WebSocketServer } = require('ws');
const Redis = require('ioredis');
const http = require('http');

const PORT = process.env.PORT || 3000;
const INSTANCE_ID = process.env.INSTANCE_ID || 'node-default';
const REDIS_HOST = process.env.REDIS_HOST || 'localhost';

// HTTP Server dasar untuk inisialisasi WebSocket
const server = http.createServer((req, res) => {
  res.writeHead(200, { 'Content-Type': 'text/plain' });
  res.end(`Node.js Real-time Worker [${INSTANCE_ID}] is healthy\n`);
});

const wss = new WebSocketServer({ server });

// Klien Redis untuk mempublikasikan pesan
const pubClient = new Redis({ host: REDIS_HOST, port: 6379 });
// Klien Redis terpisah Wajib hukumnya untuk proses subscribe
const subClient = new Redis({ host: REDIS_HOST, port: 6379 });

const CHANNEL_NAME = 'global_chat_channel';

// Berlangganan ke Redis Channel
subClient.subscribe(CHANNEL_NAME, (err, count) => {
  if (err) {
    console.error(`[${INSTANCE_ID}] Gagal subscribe ke Redis:`, err);
    return;
  }
  console.log(`[${INSTANCE_ID}] Berhasil subscribe ke ${count} channel Redis.`);
});

// Ketika pesan datang dari Redis (dikirim oleh pod manapun)
subClient.on('message', (channel, message) => {
  if (channel === CHANNEL_NAME) {
    const parsedMessage = JSON.parse(message);
    
    // Jangan kirim balik ke pengirim asli jika berasal dari instance ini (opsional)
    console.log(`[${INSTANCE_ID}] Menerima pesan dari Redis broker:`, parsedMessage);

    // Broadcast ke seluruh klien yang terhubung ke pod ini
    wss.clients.forEach((client) => {
      if (client.readyState === WebSocket.OPEN) {
        client.send(JSON.stringify(parsedMessage));
      }
    });
  }
});

// Manajemen koneksi WebSocket klien
wss.on('connection', (ws, req) => {
  console.log(`[${INSTANCE_ID}] Klien baru terhubung dari IP: ${req.socket.remoteAddress}`);

  ws.on('message', async (data) => {
    try {
      const payload = JSON.parse(data.toString());
      console.log(`[${INSTANCE_ID}] Menerima pesan dari klien:`, payload);

      // Publikasikan ke Redis agar disebar ke seluruh pod kluster
      await pubClient.publish(CHANNEL_NAME, JSON.stringify({
        senderInstance: INSTANCE_ID,
        data: payload.text,
        timestamp: Date.now()
      }));
    } catch (error) {
      console.error(`[${INSTANCE_ID}] Kesalahan memproses pesan WebSocket:`, error);
      ws.send(JSON.stringify({ error: 'Format pesan tidak valid' }));
    }
  });

  ws.on('close', () => {
    console.log(`[${INSTANCE_ID}] Klien memutuskan sambungan.`);
  });

  ws.on('error', (error) => {
    console.error(`[${INSTANCE_ID}] WebSocket error terjadi:`, error);
  });
});

server.listen(PORT, () => {
  console.log(`[${INSTANCE_ID}] Server berjalan mulus pada port ${PORT}`);
});
```

> [!IMPORTANT]
> Perhatikan bahwa kita menggunakan **dua instance klien Redis terpisah** (`pubClient` dan `subClient`). Di dalam arsitektur Redis, begitu sebuah klien masuk ke mode *subscriber*, ia tidak dapat lagi menjalankan perintah database biasa seperti `GET` atau `SET` pada koneksi yang sama. Mengabaikan aturan ini adalah penyebab utama error misterius di lingkungan produksi.

## 6. Membedah Baris Kode Krusial yang Kerap Menjebak

Menulis kode WebSocket yang berjalan di lingkungan *development* sangatlah mudah. Tantangan sebenarnya muncul ketika kode tersebut hidup di produksi dan berhadapan dengan fenomena jaringan seluler yang tidak stabil, gangguan *proxy* korporat, serta kelalaian manajemen memori.

Mari kita perhatikan bagian krusial yang sering dilupakan oleh para pengembang: **Heartbeat / Ping-Pong Mechanism**. Koneksi TCP dapat terputus secara senyap (*half-open connection*) ketika kabel jaringan tercabut secara paksa atau perangkat pengguna masuk ke mode tidur (*sleep mode*) tanpa sempat mengirimkan frame `CLOSE`. Tanpa mekanisme deteksi aktif, server Node.js Anda akan mengira klien tersebut masih terhubung, terus menimbun referensi objek di dalam RAM, hingga akhirnya mengalami kebocoran memori (*memory leak*) yang masif.

Mari tambahkan logika *heartbeat* yang kokoh ke dalam berkas `server.js` kita:

```javascript
// Fungsi untuk mendeteksi klien zombie
function heartbeat() {
  this.isAlive = true;
}

wss.on('connection', (ws, req) => {
  ws.isAlive = true;
  ws.on('pong', heartbeat);
  
  // ... (logika pesan lainnya)
});

// Interval pemeriksaan berkala di tingkat server (misal setiap 30 detik)
const interval = setInterval(() => {
  wss.clients.forEach((ws) => {
    if (ws.isAlive === false) {
      console.log('Menutup koneksi klien zombie yang tidak merespon ping.');
      return ws.terminate();
    }

    ws.isAlive = false;
    ws.ping();
  });
}, 30000);

wss.on('close', () => {
  clearInterval(interval);
});
```

Potongan kode di atas memastikan bahwa setiap klien yang "mati suri" akan secara otomatis dibersihkan dari memori server dalam kurun waktu maksimal 30 detik, menyelamatkan RAM server Anda dari tumpukan koneksi sampah.

## 7. Bertahan di Bawah Tekanan: Skenario Traffic Nyata

Teori dan kode lokal sudah teruji, kini saatnya kita membayangkan skenario nyata di lapangan. Bayangkan platform Anda sedang mengadakan *flash sale* atau siaran langsung olahraga akbar. Dalam waktu lima detik, 50.000 pengguna baru membuka aplikasi secara bersamaan.

Pada detik-detik pertama, lonjakan permintaan *HTTP Upgrade* akan membanjiri Nginx. Jika konfigurasi `worker_connections` di Nginx atau batas file descriptor sistem operasi Linux (`ulimit -n`) Anda masih berada di angka default (biasanya 1024), Nginx akan langsung menolak koneksi masuk dengan error `502 Bad Gateway` atau `Too many open files`. Di sisi Node.js, *Event Loop* tunggal akan bekerja sangat keras memproses *handshake* TLS dan WebSocket. Jika payload inisialisasi terlalu besar, Anda akan melihat lonjakan latensi (*event loop lag*) yang membuat seluruh pengiriman pesan tertunda.

Untuk bertahan di bawah tekanan ekstrem ini, pastikan Anda menyetel ulang parameter kernel Linux pada server penyiaran Anda (`/etc/security/limits.conf`):

```text
* soft nofile 65535
* hard nofile 65535
```

Dan sesuaikan parameter TCP di `/etc/sysctl.conf` agar kernel mampu mendaur ulang soket jaringan yang sudah tertutup dengan cepat:

```text
net.ipv4.tcp_fin_timeout = 15
net.ipv4.tcp_tw_reuse = 1
net.core.somaxconn = 65535
```

Penyesuaian tingkat sistem operasi ini seringkali menjadi pembeda antara aplikasi yang ambruk total saat *peak traffic* dan aplikasi yang tetap berjalan tenang melayani jutaan pengguna.

## 8. Jebakan Klasik & Cara Menghindarinya Sebelum Meledak

Selama bertahun-tahun merancang arsitektur berskala besar, saya melihat pola kesalahan berulang yang dilakukan oleh para engineer berbakat sekalipun. Berikut adalah daftar jebakan klasik yang wajib Andahindari:

1. **Melakukan Broadcast Tanpa Filter Klien Pengirim**: Ketika seorang pengguna mengetik pesan chat, seringkali aplikasi langsung melakukan *broadcast* ke seluruh penjuru Redis tanpa membedakan apakah pesan tersebut harus dikembalikan ke pengirim aslinya. Ini menghasilkan *network noise* yang tidak perlu dan membuat antarmuka pengguna di sisi klien terasa lambat karena harus merender ulang pesan miliknya sendiri.
2. **Mengabaikan Backpressure Handling**: Apa yang terjadi ketika klien Anda berada di jaringan seluler yang buruk (misalnya 2G di dalam kereta bawah tanah), sementara server terus menerus mengirimkan ribuan pesan per detik? Buffer pengiriman di memori server akan membengkak drastis karena klien tidak sanggup mengonsumsinya. Selalu pantau properti `ws.bufferedAmount` sebelum mengirimkan data berat.
3. **Ketergantungan pada IP Client Tanpa X-Forwarded-For**: Di balik *reverse proxy* seperti Nginx atau Cloudflare, alamat IP asli klien akan tersamarkan menjadi IP internal *proxy*. Jika aplikasi Anda melakukan *rate limiting* berdasarkan `req.socket.remoteAddress`, Anda akan memblokir Nginx itu sendiri alurnya.

> [!TIP]
> Gunakan pustaka pengelola state terpusat yang aman. Jika Anda ingin melacak pengguna mana yang terhubung ke pod mana, simpan pemetaan tersebut di Redis dengan *TTL (Time-to-Live)* yang diperbarui secara berkala melalui *heartbeat* klien.

## 9. Uji Nyali: Mengukur Performa di Titik Batas

Jangan pernah mempercayai klaim performa sebelum Anda membuktikannya sendiri melalui *stress testing*. Menguji sistem WebSocket jauh lebih rumit daripada menguji API HTTP REST biasa karena alat uji harus mampu menjaga ribuan koneksi TCP tetap terbuka secara bersamaan sambil mengirimkan pesan interaktif.

Salah satu alat terbaik di industri saat ini adalah **Artillery.io** atau **Autocannon** khusus untuk HTTP, namun untuk WebSocket, Artillery menyediakan skenario pengujian berbasis skrip YAML yang sangat kuat. Buatlah berkas pengujian `load-test.yml`:

```yaml config:
  target: "http://localhost"
  phases:
    - duration: 60
      arrivalRate: 50
      rampTo: 500
      name: "Lonjakan bertahap hingga 500 koneksi per detik"
  engines:
    ws: {}
scenarios:
  - engine: "ws"
    flow:
      - connect: ""
      - send: "{\"text\": \"Halo dari load test Artillery!\\