Dashboard live data sering terlihat sederhana di sisi antarmuka: angka berubah, tabel bertambah, grafik bergerak. Namun di balik itu, ada beberapa persoalan teknis yang harus diselesaikan dengan benar: bagaimana data dari banyak worker/backend dikumpulkan, bagaimana update dikirim ke browser tanpa membanjiri koneksi, bagaimana autentikasi dan pembatasan channel diterapkan, dan bagaimana sistem tetap stabil saat jumlah koneksi meningkat.
Dalam artikel ini kita akan membangun pendekatan yang realistis menggunakan Nuxt 3 di sisi frontend, WebSocket untuk komunikasi real-time, dan Redis Pub/Sub sebagai tulang punggung distribusi event antar proses. Tujuannya bukan demo mainan, tetapi pola implementasi yang bisa dipakai untuk dashboard operasional, monitoring, order tracking, log stream, atau metrik bisnis internal.
Arsitektur Dasar: Dari Banyak Worker ke Banyak Browser
Masalah utama dashboard real-time biasanya bukan membuat koneksi WebSocket, tetapi menyatukan aliran data dari banyak sumber. Misalnya:
- worker pemrosesan job mengirim status task,
- service pembayaran mengirim perubahan status transaksi,
- service inventory mengirim update stok,
- backend utama mengirim notifikasi untuk tenant atau user tertentu.
Jika setiap worker mencoba berbicara langsung ke browser, arsitektur menjadi rumit. Solusi yang umum dipakai adalah event bus di sisi server. Redis Pub/Sub cocok untuk ini karena sederhana, cepat, dan cukup untuk distribusi event ephemeral.
Alur data
- Worker/backend mem-publish event ke channel Redis, misalnya
dashboard:tenant:42:orders. - Server WebSocket melakukan subscribe ke channel yang relevan.
- Server WebSocket memvalidasi user, tenant, dan channel yang boleh diakses.
- Server meneruskan event hanya ke client yang berhak.
- Client Nuxt 3 menyinkronkan state lokal secara aman dan efisien.
Pola ini bekerja baik untuk horizontal scaling karena banyak instance server WebSocket dapat sama-sama subscribe ke Redis. Event yang dipublish worker akan terlihat oleh semua instance, lalu tiap instance hanya mengirim ke koneksi yang sedang menempel padanya.
Catatan: Redis Pub/Sub tidak menyimpan histori. Jika client terputus, event yang lewat saat itu akan hilang. Untuk dashboard yang memerlukan recovery state, kombinasikan dengan API snapshot atau message stream yang persisten.
Desain Event dan Channel yang Aman
Kesalahan umum adalah membiarkan client bebas menentukan channel apa pun. Itu berbahaya karena user bisa mencoba subscribe ke tenant lain atau data sensitif lain. Sebaiknya channel yang dapat diakses diturunkan dari hasil autentikasi, bukan dari input sembarang.
Contoh struktur event
{
"type": "order.updated",
"tenantId": "42",
"entityId": "ORD-9912",
"timestamp": 1710000000000,
"seq": 10421,
"payload": {
"status": "paid",
"amount": 250000
}
}Beberapa hal penting dari format event di atas:
- type memudahkan routing di client.
- timestamp berguna untuk observability dan debugging latency.
- seq membantu mendeteksi event out-of-order atau gap.
- tenantId memudahkan validasi isolasi data.
Pembatasan channel
Hindari pola seperti subscribe(channelNameFromClient) tanpa validasi. Lebih aman jika client hanya mengirim intent, misalnya { action: 'subscribe', topic: 'orders' }, lalu server menerjemahkannya menjadi channel internal berbasis tenant dan role user.
function resolveAllowedChannels(user: { tenantId: string; roles: string[] }) {
const channels = [`dashboard:tenant:${user.tenantId}:metrics`]
if (user.roles.includes('ops')) {
channels.push(`dashboard:tenant:${user.tenantId}:orders`)
channels.push(`dashboard:tenant:${user.tenantId}:jobs`)
}
return channels
}Dengan pendekatan ini, user tidak pernah memegang nama channel internal yang bebas dimanipulasi.
Implementasi Server Adapter: Redis Pub/Sub ke WebSocket
Di Nuxt 3, praktik yang umum adalah memisahkan server WebSocket sebagai service terpisah atau berjalan berdampingan di lingkungan Node.js. Alasannya sederhana: koneksi WebSocket long-lived memiliki karakteristik berbeda dari request HTTP biasa. Memisahkannya memudahkan scaling, deployment, dan observability.
Contoh berikut menunjukkan adapter sederhana dengan ws dan Redis. Fokus contoh ini adalah struktur, bukan framework tertentu.
import { WebSocketServer } from 'ws'
import Redis from 'ioredis'
import jwt from 'jsonwebtoken'
const pubsub = new Redis(process.env.REDIS_URL)
const wss = new WebSocketServer({ port: 8081 })
const clientsByChannel = new Map<string, Set<any>>()
const subscribedRedisChannels = new Set<string>()
function addClientToChannel(channel: string, ws: any) {
if (!clientsByChannel.has(channel)) clientsByChannel.set(channel, new Set())
clientsByChannel.get(channel)!.add(ws)
}
function removeClient(ws: any) {
for (const [, set] of clientsByChannel) {
set.delete(ws)
}
}
async function ensureRedisSubscription(channel: string) {
if (subscribedRedisChannels.has(channel)) return
await pubsub.subscribe(channel)
subscribedRedisChannels.add(channel)
}
function safeSend(ws: any, data: unknown) {
if (ws.readyState !== ws.OPEN) return
// Hindari menambah backlog terlalu besar pada socket lambat
if (ws.bufferedAmount > 512 * 1024) {
ws.close(1013, 'Client too slow')
return
}
ws.send(JSON.stringify(data))
}
function authenticate(token?: string) {
if (!token) throw new Error('Missing token')
const payload = jwt.verify(token, process.env.JWT_SECRET!) as any
return {
userId: payload.sub,
tenantId: payload.tenantId,
roles: payload.roles || []
}
}
function resolveChannels(user: any) {
const channels = [`dashboard:tenant:${user.tenantId}:metrics`]
if (user.roles.includes('ops')) {
channels.push(`dashboard:tenant:${user.tenantId}:orders`)
channels.push(`dashboard:tenant:${user.tenantId}:jobs`)
}
return channels
}
pubsub.on('message', (channel, message) => {
const clients = clientsByChannel.get(channel)
if (!clients || clients.size === 0) return
let parsed: any
try {
parsed = JSON.parse(message)
} catch {
return
}
for (const ws of clients) {
safeSend(ws, parsed)
}
})
wss.on('connection', async (ws, req) => {
try {
const url = new URL(req.url!, 'http://localhost')
const token = url.searchParams.get('token') || undefined
const user = authenticate(token)
ws.user = user
const channels = resolveChannels(user)
for (const channel of channels) {
addClientToChannel(channel, ws)
await ensureRedisSubscription(channel)
}
safeSend(ws, { type: 'connection.ready', ts: Date.now() })
} catch (err: any) {
ws.close(1008, err.message || 'Unauthorized')
return
}
ws.on('close', () => removeClient(ws))
ws.on('error', () => removeClient(ws))
})Ada beberapa keputusan penting di sini:
- Autentikasi dilakukan saat handshake menggunakan token.
- Channel ditentukan server berdasarkan user, bukan input bebas dari client.
- Backpressure ditangani menggunakan
bufferedAmount. Jika socket terlalu lambat, koneksi ditutup untuk melindungi server. - Redis subscription dilakukan lazy, hanya ketika diperlukan.
Jika kebutuhan Anda lebih kompleks, misalnya perlu room management yang matang, heartbeat built-in, dan cluster adapter, library seperti Socket.IO bisa lebih nyaman. Namun jika kebutuhan Anda dominan publish/subscribe sederhana dengan payload JSON, WebSocket murni sering lebih ringan.
Integrasi di Nuxt 3: Composable Client yang Tahan Gangguan
Di sisi frontend, jangan menempelkan logika WebSocket langsung ke komponen. Buat composable agar lifecycle koneksi, reconnect, dan sinkronisasi state terpusat.
// composables/useLiveDashboard.ts
export function useLiveDashboard() {
const status = useState<'idle' | 'connecting' | 'open' | 'closed'>('ws-status', () => 'idle')
const socket = useState<WebSocket | null>('ws-socket', () => null)
const lastMessageAt = useState<number | null>('ws-last-message-at', () => null)
const reconnectAttempt = useState('ws-reconnect-attempt', () => 0)
const metrics = useState<Record<string, any>>('live-metrics', () => ({}))
const orders = useState<Record<string, any>>('live-orders', () => ({}))
let reconnectTimer: ReturnType<typeof setTimeout> | null = null
function applyEvent(event: any) {
lastMessageAt.value = Date.now()
switch (event.type) {
case 'metric.updated':
metrics.value[event.payload.key] = event.payload.value
break
case 'order.updated':
orders.value[event.entityId] = {
...(orders.value[event.entityId] || {}),
...event.payload,
updatedAt: event.timestamp
}
break
}
}
function scheduleReconnect() {
if (reconnectTimer) return
reconnectAttempt.value += 1
const delay = Math.min(1000 * 2 ** reconnectAttempt.value, 15000)
reconnectTimer = setTimeout(() => {
reconnectTimer = null
connect()
}, delay)
}
function connect() {
if (process.server) return
if (socket.value && socket.value.readyState === WebSocket.OPEN) return
status.value = 'connecting'
const token = useCookie('access_token').value
const ws = new WebSocket(`ws://localhost:8081?token=${encodeURIComponent(token || '')}`)
socket.value = ws
ws.onopen = () => {
status.value = 'open'
reconnectAttempt.value = 0
}
ws.onmessage = (e) => {
try {
const event = JSON.parse(e.data)
applyEvent(event)
} catch {
// abaikan payload rusak
}
}
ws.onclose = () => {
status.value = 'closed'
scheduleReconnect()
}
ws.onerror = () => {
ws.close()
}
}
async function refreshSnapshot() {
const [m, o] = await Promise.all([
$fetch('/api/dashboard/metrics'),
$fetch('/api/dashboard/orders')
])
metrics.value = m
orders.value = o
}
return {
status,
metrics,
orders,
connect,
refreshSnapshot,
lastMessageAt
}
}Composable ini memiliki beberapa fungsi penting:
- menyimpan state socket global,
- menerapkan event ke state yang reaktif,
- reconnect dengan exponential backoff,
- menyediakan snapshot refresh melalui HTTP API.
Kenapa snapshot tetap penting?
Karena WebSocket biasanya mengirim delta atau perubahan incremental. Jika client baru masuk, reconnect setelah putus, atau mendeteksi gap sequence, client perlu memuat ulang state dari API agar konsisten. Pola yang aman adalah:
- saat halaman dibuka, ambil snapshot awal via HTTP,
- setelah itu aktifkan WebSocket untuk perubahan incremental,
- jika reconnect terjadi atau sequence lompat, ambil snapshot ulang.
Dengan begitu, Anda tidak menggantungkan kebenaran state sepenuhnya pada stream event yang sifatnya tidak persisten.
Reconnect, Heartbeat, dan Fallback ke Polling
Koneksi real-time di internet publik tidak selalu stabil. Browser bisa tidur, proxy bisa memutus idle connection, jaringan pengguna bisa berpindah dari Wi-Fi ke seluler. Karena itu, reconnect bukan fitur tambahan, melainkan bagian inti desain.
Strategi reconnect yang sehat
- Gunakan exponential backoff agar tidak menyerbu server saat outage.
- Reset backoff jika koneksi sempat berhasil terbuka.
- Sinkronkan ulang state setelah reconnect.
- Jangan reconnect tanpa batas jika token sudah tidak valid; arahkan ke login ulang.
Heartbeat
Untuk mendeteksi koneksi zombie, server dapat mengirim ping periodik atau message heartbeat. Jika dalam interval tertentu client tidak menerima apa pun, client bisa menutup koneksi dan memicu reconnect. Ini membantu ketika koneksi secara logis mati tetapi browser belum memicu onclose.
Fallback ke polling
Beberapa lingkungan jaringan memblokir atau mengganggu WebSocket. Untuk dashboard operasional, fallback ke polling berkala sering lebih baik daripada blank screen.
// contoh sederhana fallback
watchEffect(() => {
if (status.value === 'closed') {
const interval = setInterval(() => {
refreshSnapshot()
}, 5000)
onScopeDispose(() => clearInterval(interval))
}
})Polling memang tidak se-real-time WebSocket, tetapi tetap menjaga dashboard berguna ketika kanal live gagal. Trade-off-nya adalah beban HTTP lebih tinggi dan latency update lebih besar.
Backpressure, Batching, dan Sinkronisasi State
Salah satu penyebab dashboard live terasa lambat bukan koneksi, tetapi terlalu banyak update kecil yang memicu render berkali-kali. Jika worker mempublish ribuan event per detik, browser bisa kewalahan walaupun server masih kuat.
Teknik mengurangi tekanan
- Coalescing: gabungkan beberapa update untuk entitas yang sama dalam jendela waktu singkat.
- Batching: kirim array event setiap 100–500 ms untuk metrik frekuensi tinggi.
- Sampling: tidak semua event perlu ditampilkan mentah; kadang cukup agregasi per detik.
- Drop policy: untuk stream yang sangat cepat, update lama yang belum terkirim bisa dibuang dan diganti state terbaru.
Di server, bufferedAmount pada socket adalah sinyal penting. Jika angka itu terus naik, client tidak mampu mengonsumsi data secepat server mengirim. Menutup koneksi lambat kadang lebih aman daripada membiarkan memori server tumbuh tanpa kontrol.
Di client, gunakan struktur state yang sesuai. Untuk tabel order, simpan dalam map berdasarkan entityId lalu turunkan ke array untuk render. Ini lebih efisien daripada mencari item dalam array setiap event datang.
Horizontal Scaling dan Topologi Deployment
Ketika satu instance WebSocket tidak cukup, Anda bisa menjalankan banyak instance di belakang load balancer. Ada dua hal penting:
- setiap instance harus menerima event dari sumber yang sama,
- client harus tetap stabil walau terhubung ke instance mana pun.
Pola umum
- Worker mempublish ke Redis Pub/Sub.
- Semua instance WebSocket subscribe ke channel yang dibutuhkan.
- Load balancer mendistribusikan koneksi masuk.
- Tiap instance hanya mengirim ke koneksi lokalnya.
Untuk kasus ini, sticky session biasanya tidak wajib jika state koneksi cukup ada di memori tiap instance dan event berasal dari Redis. Namun jika Anda menyimpan state sesi tertentu secara lokal yang dibutuhkan setelah reconnect cepat, sticky session bisa membantu. Tetap saja, desain yang lebih tahan terhadap scaling adalah meminimalkan ketergantungan pada state lokal instance.
Perlu diingat bahwa Redis Pub/Sub cocok untuk fan-out event, tetapi bukan message queue yang menjamin delivery. Jika Anda membutuhkan replay, audit, atau pemrosesan tahan gagal, pertimbangkan Redis Streams, Kafka, atau penyimpanan event terpisah.
Keamanan: Auth Koneksi, Tenant Isolation, dan Batasan Akses
Autentikasi WebSocket sering diabaikan karena koneksinya panjang, bukan request-response biasa. Praktik yang cukup umum adalah memakai token JWT saat handshake. Beberapa hal yang perlu diperhatikan:
- verifikasi tanda tangan token di server,
- pastikan tenantId dan role berasal dari token atau lookup server-side,
- jangan percaya user untuk memilih channel sensitif,
- batasi ukuran message dari client,
- jika ada command dua arah, validasi schema payload secara ketat.
Untuk dashboard yang hanya menerima data dari server, bahkan lebih aman jika client tidak diizinkan mengirim message selain ping atau subscribe intent yang sangat terbatas.
Pengujian Stabilitas Saat Banyak Koneksi Aktif
Sistem real-time sering terlihat baik dengan 5 tab browser, lalu gagal ketika ada ratusan atau ribuan koneksi. Karena itu, lakukan pengujian yang meniru pola nyata:
Apa yang perlu diuji
- jumlah koneksi bersamaan,
- frekuensi event per channel,
- client lambat dengan jaringan buruk,
- reconnect massal setelah restart server,
- pertumbuhan memori dan file descriptor.
Pendekatan pengujian
Gunakan skrip Node.js sederhana atau tool load test untuk membuka banyak koneksi WebSocket lalu kirim event melalui Redis. Pantau:
- CPU dan memori instance WebSocket,
- nilai
bufferedAmountyang sering tinggi, - latency dari publish Redis ke render client,
- jumlah koneksi yang putus akibat backpressure,
- error rate saat reconnect.
import WebSocket from 'ws'
const connections = []
for (let i = 0; i < 500; i++) {
const ws = new WebSocket('ws://localhost:8081?token=TEST_TOKEN')
ws.on('open', () => console.log('open', i))
ws.on('message', () => {})
ws.on('close', () => console.log('close', i))
connections.push(ws)
}Skrip di atas memang sederhana, tetapi cukup untuk mulai melihat apakah server Anda bocor memori, lambat menerima koneksi, atau tidak tahan terhadap lonjakan reconnect.
Debugging tip
- Log connection id, tenantId, dan channel count saat connect/disconnect.
- Tambahkan sequence number pada event untuk mendeteksi kehilangan urutan.
- Simpan metrik jumlah socket aktif, socket ditutup karena lambat, dan delay publish-to-send.
- Pastikan ada timeout untuk operasi auth eksternal agar handshake tidak menggantung.
Penutup
Membangun dashboard live data dengan Nuxt 3, WebSocket, dan Redis bukan sekadar membuka socket lalu mengirim JSON. Sistem yang layak dipakai di produksi perlu memikirkan aliran event dari banyak worker, isolasi tenant, otorisasi channel, reconnect, sinkronisasi ulang state, fallback ketika kanal live gagal, serta perlindungan terhadap backpressure.
Pola yang paling praktis biasanya adalah snapshot via HTTP + delta via WebSocket + distribusi event via Redis Pub/Sub. Kombinasi ini cukup sederhana untuk dioperasikan, tetapi tetap fleksibel untuk berkembang ke banyak instance. Jika nanti kebutuhan Anda naik ke replay event, delivery guarantee, atau analitik stream yang lebih kaya, Anda masih bisa berevolusi ke arsitektur yang lebih kompleks tanpa membuang pola client dan sinkronisasi state yang sudah baik.
Mulailah dari event yang rapi, channel yang aman, observability yang cukup, dan uji kestabilan sejak awal. Di sistem real-time, masalah biasanya muncul bukan saat demo, tetapi saat koneksi banyak, data deras, dan jaringan tidak ideal.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!