L
Redis • Literally Web ID

Membangun Real-Time Collaborative Editor dengan WebSockets & Redis Cluster

⏱️ 18 menit baca • 📅 13 Oct 2026 • 👁️ 1 views
Membangun Real-Time Collaborative Editor dengan WebSockets & Redis Cluster

1. Konteks Industri & Analisis Akar Masalah

Dalam lanskap pengembangan aplikasi web modern, kolaborasi real-time bukan lagi sekadar fitur tambahan (nice-to-have), melainkan standar industri yang wajib dipenuhi. Aplikasi seperti Google Docs, Figma, dan Notion telah mengubah ekspektasi pengguna: setiap perubahan teks, pergerakan kursor, atau manipulasi state harus disinkronkan lintas perangkat dalam hitungan milidetik tanpa penyegaran halaman (refresh).

Namun, membangun sistem kolaboratif yang skalabel menghadirkan tantangan teknik yang kompleks. Pendekatan tradisional berbasis HTTP Request-Response (seperti Short Polling atau Long Polling) gagal memenuhi kebutuhan ini. Short polling membanjiri server dengan ribuan permintaan kosong yang memakan CPU, sementara long polling mempertahankan koneksi HTTP terbuka yang membebaskan memori server secara tidak efisien dan rentan terhadap masalah file descriptor exhaustion pada sistem operasi Linux.

Bottleneck Performa dan Masalah Arsitektur

Ketika aplikasi mulai berkembang dan melayani puluhan ribu hingga jutaan pengguna aktif secara bersamaan, beberapa bottleneck kritis muncul:

  1. Keterbatasan Single Node WebSocket: Sebuah instance Node.js tunggal dapat menangani puluhan ribu koneksi WebSocket, tetapi memori dan CPU akan habis jika proses komputasi dokumen atau sinkronisasi state dilakukan secara terpusat.
  2. Horizontal Scaling Silo (Pulau Koneksi): Jika Anda menggunakan Load Balancer (seperti Nginx) untuk mendistribusikan traffic ke 3 instance server WebSocket (Server A, B, C), seorang pengguna di Server A tidak akan menerima pesan dari pengguna di Server B secara langsung. Koneksi WebSocket bersifat stateful dan terisolasi pada masing-masing instance.
  3. Konflik State & Race Condition: Tanpa mekanisme penanganan konflik yang solid (seperti Operational Transformation / OT atau Conflict-free Replicated Data Types / CRDT), dua pengguna yang mengetik pada baris yang sama secara bersamaan akan menyebabkan korupsi data (data overwriting).

Perbandingan Kondisi: Sebelum vs Sesudah Penerapan

Parameter Sebelum (Polling / Monolitik) Sesudah (WebSocket + Redis Cluster)
Latensi Jaringan 500ms - 2000ms (Delay polling) < 50ms (Full-duplex real-time)
Beban Server (CPU/RAM) Sangat Tinggi (Banyak HTTP overhead) Rendah & Stabil (Persistent TCP connections)
Skalabilitas Terbatas pada kapasitas satu server Skalabel secara horizontal tanpa batas (stateless nodes)
Konsistensi Data Sering terjadi race condition & hilang Konsisten dengan Redis Pub/Sub & CRDT/OT

[!WARNING] Jangan pernah mencoba menyimpan state sesi WebSocket secara lokal di memori proses aplikasi tanpa lapisan broker eksternal jika Anda berencana melakukan auto-scaling infrastruktur di Kubernetes atau AWS ECS.


2. Konsep Inti & Deep Dive Arsitektur

Untuk mengatasi isolasi koneksi antar-server, kita memerlukan arsitektur terdistribusi yang memadukan protokol komunikasi stateful (WebSocket) dengan sistem message broker berkecepatan tinggi yang mendukung pola Publish-Subscribe (Redis Cluster).

Alur Eksekusi di Balik Layar (Under the Hood)

  1. Koneksi Klien: Klien (browser) melakukan handshake HTTP ke Load Balancer (Nginx), yang kemudian meneruskannya ke salah satu instance backend Node.js (misal: Node-1).
  2. Registrasi Sesi: Node-1 menerima koneksi WebSocket dan mempertahankannya dalam memori RAM.
  3. Publikasi Perubahan: Klien mengetik teks. Pesan dikirim melalui WebSocket ke Node-1.
  4. Broadcasting Lintas Node via Redis: Node-1 tidak hanya mengirim pesan ke klien lokalnya, tetapi juga mempublikasikannya (publish) ke channel Redis tertentu.
  5. Distribusi: Redis Cluster mendistribusikan pesan tersebut ke semua node backend lain (Node-2, Node-3) yang berlangganan (subscribe) pada channel tersebut.
  6. Sinkronisasi Akhir: Node-2 dan Node-3 menerima pesan dari Redis dan meneruskannya ke klien mereka masing-masing yang sedang membuka dokumen yang sama.

Visualisasi Arsitektur Sistem

+----------+       +----------+       +---------------------------------------+
| Client A |       | Client B |       |               Client C                |
+----+-----+       +----+-----+       +-------------------+-------------------+
     |                  |                                 |
     +------------------+--------+------------------------+
                                 |
                        +--------v--------+
                        |  Nginx LB (SSL) |
                        +--------+--------+
                                 |
         +-----------------------+-----------------------+
         |                       |                       |
+--------v--------+     +--------v--------+     +--------v--------+
| Node.js Server 1|     | Node.js Server 2|     | Node.js Server 3|
|  (WebSocket)    |     |  (WebSocket)    |     |  (WebSocket)    |
+--------+--------+     +--------+--------+     +--------+--------+
         |                       |                       |
         +-----------------------+-----------------------+
                                 |
                     +-----------v-----------+
                     |     Redis Cluster     | 
                     | (Master-Replica Nodes)|
                     +-----------------------+

3. Perbandingan Komparatif / Analisis Pendekatan

Dalam merancang sistem real-time, pemilihan teknologi message broker dan protokol transfer data sangat menentukan performa jangka panjang.

Komparasi Broker: Redis Pub/Sub vs Apache Kafka vs RabbitMQ

Fitur / Metrik Redis Pub/Sub Apache Kafka RabbitMQ
Latensi Ultra-rendah (< 5ms) Rendah (10-20ms) Menengah (15-30ms)
Persistensi Pesan Tidak ada (Fire and forget) Ya (Log berbasis disk) Ya (Opsional di antrian)
Kompleksitas Setup Sangat Rendah Tinggi Menengah
Penggunaan RAM Tinggi (In-memory) Menengah (Caching) Rendah-Menengah
Kasus Penggunaan Ideal Real-time chat, kursor, editor Event sourcing, big data Task queue, background jobs

[!TIP] Untuk collaborative editor, Redis Pub/Sub adalah pilihan terbaik karena kita hanya peduli pada state terbaru dan latensi instan. Kita tidak memerlukan riwayat pesan mentah di message broker karena riwayat dokumen disimpan di database utama (PostgreSQL/MongoDB).


4. Prasyarat Sistem & Persiapan Lingkungan

Sebelum mulai menulis kode, pastikan lingkungan server (baik lokal maupun VPS produksi berbasis Ubuntu 22.04 LTS) memenuhi spesifikasi berikut:

  • Node.js: Versi 20.x LTS atau terbaru.
  • Redis: Redis Server versi 7.x (disarankan konfigurasi Cluster minimal 3 Master dan 3 Replica).
  • Nginx: Versi 1.18+ (sebagai Reverse Proxy & WebSocket Gateway).
  • Sistem Operasi: Linux (Ubuntu/Debian dengan penyesuaian ulimit untuk file descriptors).

Penyesuaian Kernel Linux untuk High Concurrency

Ubah konfigurasi sistem operasi agar mampu menangani puluhan ribu koneksi TCP bersamaan. Edit file /etc/security/limits.conf:

* soft nofile 65535
* hard nofile 65535
root soft nofile 65535
root hard nofile 65535

Dan terapkan perubahan sysctl pada /etc/sysctl.conf:

fs.file-max = 2097152
net.ipv4.tcp_max_syn_backlog = 8192
net.core.somaxconn = 65535
net.ipv4.ip_local_port_range = 1024 65535

Jalankan perintah berikut untuk menerapkan:

sysctl -p

5. Panduan Implementasi Langkah demi Langkah

Langkah 1: Inisialisasi Proyek Node.js

Buat direktori baru dan instal dependensi yang diperlukan (ws, ioredis, uuid).

mkdir real-time-editor
cd real-time-editor
npm init -y
npm install ws ioredis uuid dotenv

Langkah 2: Konfigurasi Redis Cluster & Koneksi Node.js

Buat file .env untuk konfigurasi environment:

PORT=3000
REDIS_HOST=127.0.0.1
REDIS_PORT=6379

Buat file utama server.js dengan kode lengkap di bawah ini:

require('dotenv').config();
const http = require('http');
const { WebSocketServer } = require('ws');
const Redis = require('ioredis');
const { v4: uuidv4 } = require('uuid');

const server = http.createServer((req, res) => {
  res.writeHead(200, { 'Content-Type': 'text/plain' });
  res.end('Real-Time Collaborative Editor Server is Running\n');
});

const wss = new WebSocketServer({ server });

// Inisialisasi Redis Publisher dan Subscriber
// Catatan: Redis Pub/Sub memerlukan koneksi terpisah untuk sub dan pub
const redisPub = new Redis({
  host: process.env.REDIS_HOST,
  port: process.env.REDIS_PORT,
});

const redisSub = new Redis({
  host: process.env.REDIS_HOST,
  port: process.env.REDIS_PORT,
});

const CHANNEL_NAME = 'editor_sync_channel';

// Menyimpan koneksi klien aktif di memori lokal server ini
// Map<documentId, Set<WebSocket>>
const documentClients = new Map();

redisSub.subscribe(CHANNEL_NAME, (err, count) => {
  if (err) {
    console.error('Gagal subscribe ke Redis channel:', err);
  } else {
    console.log(`Berhasil subscribe ke ${CHANNEL_NAME}. Total subs: ${count}`);
  }
});

// Menerima pesan dari instance lain melalui Redis Pub/Sub
redisSub.on('message', (channel, messageString) => {
  if (channel === CHANNEL_NAME) {
    try {
      const parsedMessage = JSON.parse(messageString);
      const { documentId, senderId, payload } = parsedMessage;

      if (documentClients.has(documentId)) {
        const clients = documentClients.get(documentId);
        clients.forEach((client) => {
          // Jangan kirim balik ke pengirim asli yang terhubung ke node ini (opsional)
          if (client.readyState === 1 && client.id !== senderId) {
            client.send(JSON.stringify(payload));
          }
        });
      }
    } catch (e) {
      console.error('Error parsing Redis message:', e);
    }
  }
});

wss.on('connection', (ws, req) => {
  ws.id = uuidv4();
  let currentDocumentId = null;

  console.log(`Klien baru terhubung dengan ID: ${ws.id}`);

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

      if (message.type === 'JOIN_DOCUMENT') {
        currentDocumentId = message.documentId;
        if (!documentClients.has(currentDocumentId)) {
          documentClients.set(currentDocumentId, new Set());
        }
        documentClients.get(currentDocumentId).add(ws);
        console.log(`Klien ${ws.id} bergabung ke dokumen: ${currentDocumentId}`);
      }

      if (message.type === 'EDIT_DOCUMENT') {
        const { documentId, content, delta } = message;

        // Broadcast ke Redis Cluster agar node lain juga meneruskannya ke klien mereka
        const payloadToBroadcast = {
          documentId,
          senderId: ws.id,
          payload: {
            type: 'UPDATE_DOCUMENT',
            content,
            delta,
            senderId: ws.id,
          },
        };

        redisPub.publish(CHANNEL_NAME, JSON.stringify(payloadToBroadcast));

        // Kirim juga ke klien lokal di node ini
        if (documentClients.has(documentId)) {
          documentClients.get(documentId).forEach((client) => {
            if (client !== ws && client.readyState === 1) {
              client.send(JSON.stringify(payloadToBroadcast.payload));
            }
          });
        }
      }
    } catch (err) {
      console.error('Error memproses pesan WebSocket:', err);
    }
  });

  ws.on('close', () => {
    if (currentDocumentId && documentClients.has(currentDocumentId)) {
      const clients = documentClients.get(currentDocumentId);
      clients.delete(ws);
      if (clients.size === 0) {
        documentClients.delete(currentDocumentId);
      }
    }
    console.log(`Klien terputus: ${ws.id}`);
  });
});

const PORT = process.env.PORT || 3000;
server.listen(PORT, () => {
  console.log(`Server berjalan di port ${PORT}`);
});

Langkah 3: Konfigurasi Nginx Reverse Proxy untuk WebSocket

Agar WebSocket dapat melewati Nginx dengan aman dan mendukung koneksi persisten Keep-Alive, buat blok konfigurasi Nginx berikut pada /etc/nginx/sites-available/editor:

map $http_upgrade $connection_upgrade {
    default upgrade;
    ''      close;
}

upstream websocket_backend {
    server 127.0.0.1:3000;
    server 127.0.0.1:3001;
    # Tambahkan node backend lainnya di sini untuk load balancing
}

server {
    listen 80;
    server_name editor.example.com;
    return 301 https://$host$request_uri;
}

server {
    listen 443 ssl http2;
    server_name editor.example.com;

    ssl_certificate /etc/letsencrypt/live/editor.example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/editor.example.com/privkey.pem;
    ssl_protocols TLSv1.2 TLSv1.3;
    ssl_ciphers HIGH:!aNULL:!MD5;

    location / {
        proxy_pass http://websocket_backend;
        proxy_http_version 1.1;
        
        # Header krusial untuk upgrade koneksi HTTP ke WebSocket
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection $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;
        proxy_set_header X-Forwarded-Proto $scheme;

        # Timeout untuk koneksi WebSocket jangka panjang (misal 24 jam)
        proxy_read_timeout 86400s;
        proxy_send_timeout 86400s;
    }
}

6. Bedah Kode & Penjelasan Detail Baris Kunci

  1. Pemisahan redisPub dan redisSub:

    const redisPub = new Redis({...});
    const redisSub = new Redis({...});
    

    Penjelasan: Dalam klien Redis Node.js (ioredis), ketika sebuah koneksi masuk ke mode Subscriber, koneksi tersebut tidak dapat menjalankan perintah lain selain subscribe/unsubscribe. Oleh karena itu, arsitektur wajib memisahkan satu koneksi khusus untuk menerbitkan data (redisPub) dan satu koneksi untuk mendengarkan pesan masuk (redisSub).

  2. Map Lokal documentClients:

    const documentClients = new Map();
    

    Penjelasan: Struktur data Map digunakan untuk memetakan ID dokumen unik ke kumpulan (Set) objek WebSocket yang sedang aktif melihat dokumen tersebut di instance server tertentu. Ini memastikan efisiensi pencarian $O(1)$ saat melakukan broadcasting pesan lokal.

  3. Map Upgrade Nginx:

    map $http_upgrade $connection_upgrade {
        default upgrade;
        ''      close;
    }
    

    Penjelasan: Blok map ini secara dinamis menentukan nilai header Connection berdasarkan apakah klien mengirimkan header Upgrade: websocket. Jika ya, Nginx meneruskannya sebagai upgrade, memungkinkan koneksi TCP diubah menjadi terowongan WebSocket penuh.


7. Skenario Produksi & Studi Kasus Dunia Nyata

Bayangkan sebuah platform penulisan korporat yang digunakan oleh 50.000 jurnalis secara bersamaan, di mana rata-rata terdapat 500 dokumen aktif yang disunting secara paralel.

Mitigasi Lonjakan Traffic Mendadak (Flash Crowds)

Ketika terjadi peristiwa berita besar (breaking news), ribuan pengguna akan membuka dokumen yang sama secara serentak. Jika semua pesan perubahan dikirimkan tanpa optimasi, bandwidth Redis dan CPU Node.js akan mengalami kejenuhan (saturation).

Solusi Arsitektural:

  • Debounce / Throttle pada Sisi Klien: Jangan mengirim perubahan setiap huruf diketik (keypress). Gunakan teknik debounce dengan jeda 250ms atau kirim perubahan dalam bentuk patches (menggunakan pustaka seperti ot-json0 atau Yjs).
  • Redis Cluster Sharding: Pastikan Redis dikonfigurasi dengan beberapa master nodes yang di-hash berdasarkan slot kunci (hash slots), sehingga beban Pub/Sub tidak menumpuk pada satu node Redis tunggal.

8. Common Pitfalls, Anti-Patterns & Solusi

1. Memory Leak pada Koleksi Klien WebSocket

Anti-Pattern: Menyimpan referensi objek WebSocket dalam Array global tanpa membersihkannya saat koneksi terputus (disconnect). Solusi: Selalu tangani event close dan error untuk menghapus referensi klien dari struktur data memori (seperti Set atau Map) seperti yang diimplementasikan pada kode di atas.

2. Mengabaikan Ping/Pong Heartbeat

Anti-Pattern: Membiarkan koneksi WebSocket terbuka tanpa heartbeat, yang menyebabkan router jaringan perantara atau NAT gateway memutuskan (drop) koneksi diam-diam (idle timeout). Solusi: Implementasikan mekanisme Ping/Pong periodik setiap 30 detik di sisi server:

const interval = setInterval(() => {
  wss.clients.forEach((ws) => {
    if (ws.isAlive === false) return ws.terminate();
    ws.isAlive = false;
    ws.ping();
  });
}, 30000);

9. Benchmark, Metrik Kinerja & Tuning Performa

Pengujian beban (load testing) dapat dilakukan menggunakan utilitas seperti Artillery atau skrip kustom berbasis ws untuk mensimulasikan ribuan klien bersamaan.

Contoh Skrip Pengujian Menggunakan Artillery (load-test.yml):

config:
  target: "ws://localhost:3000"
  phases:
    - duration: 60
      arrivalRate: 50
      rampTo: 500
scenarios:
  - engine: "ws"
    flow:
      - send: '{"type": "JOIN_DOCUMENT", "documentId": "doc-123"}'
      - think: 2
      - send: '{"type": "EDIT_DOCUMENT", "documentId": "doc-123", "content": "Hello World"}'
      - think: 5

Jalankan pengujian dengan perintah:

artillery run load-test.yml

Metrik Target yang Harus Dicapai:

  • Latensi Pesan (P99): < 45 ms.
  • Penggunaan CPU Node.js: < 70% per instance pada beban puncak.
  • Koneksi Aktif per Node: 10.000 - 15.000 koneksi stabil.

10. Checklist Penerapan Produksi & Kesimpulan

Sebelum merilis aplikasi kolaboratif ini ke lingkungan produksi (production), pastikan Anda memverifikasi checklist berikut:

  • Konfigurasi SSL/TLS yang sah terpasang pada Nginx (wss:// protokol wajib diaktifkan).
  • Parameter kernel sistem operasi (nofile, somaxconn) telah ditingkatkan.
  • Redis Cluster dikonfigurasi dengan replikasi otomatis dan kebijakan persistence disk yang tepat (RDB/AOF).
  • Mekanisme Heartbeat (Ping/Pong) aktif untuk mencegah pemutusan koneksi oleh idle timeout firewall.
  • Manajemen log terpusat (seperti ELK Stack atau Grafana Loki) dikonfigurasi untuk memantau error koneksi WebSocket.

Dengan memadukan skalabilitas horizontal Node.js, performa instan Redis Pub/Sub, dan ketangguhan proxy Nginx, Anda kini memiliki fondasi arsitektur kelas enterprise untuk membangun aplikasi kolaboratif real-time yang handal, aman, dan tahan banting terhadap lonjakan traffic.

🏷️ Tags: #Nginx #Redis #Websocket #Architecture #Node.js