From bcaa5c25f884149a242c4f1ca166d82df90370ea Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Tue, 10 Mar 2026 18:33:17 +0100 Subject: [PATCH] perf: replace real-time polling with SSE streaming Replace 5-second setInterval polling with EventSource connection to the new /realtime/stream SSE endpoint. The server pushes visitor updates instead of each client independently polling. Auto-reconnects on connection drops. --- CHANGELOG.md | 1 + app/sites/[id]/realtime/page.tsx | 44 +++++++------------------- lib/hooks/useRealtimeSSE.ts | 53 ++++++++++++++++++++++++++++++++ 3 files changed, 65 insertions(+), 33 deletions(-) create mode 100644 lib/hooks/useRealtimeSSE.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 217b39c..6b01664 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ### Improved - **Even faster dashboard loading.** Your dashboard now fetches all its data — pages, locations, devices, referrers, performance, and goals — in a single request instead of seven separate ones. This means the entire dashboard appears at once rather than sections loading one by one, and puts much less strain on the server when many people are viewing their analytics at the same time. +- **Smoother real-time updates.** The real-time visitors page now streams updates instantly from the server instead of checking for new data every few seconds. Visitors appear and disappear in real-time with no delay, and the page uses far fewer server resources — especially when many people are watching their live traffic at the same time. ### Added diff --git a/app/sites/[id]/realtime/page.tsx b/app/sites/[id]/realtime/page.tsx index fb4b0da..0ef2c04 100644 --- a/app/sites/[id]/realtime/page.tsx +++ b/app/sites/[id]/realtime/page.tsx @@ -3,7 +3,8 @@ import { useEffect, useState } from 'react' import { useParams, useRouter } from 'next/navigation' import { getSite, type Site } from '@/lib/api/sites' -import { getRealtimeVisitors, getSessionDetails, type Visitor, type SessionEvent } from '@/lib/api/realtime' +import { getSessionDetails, type Visitor, type SessionEvent } from '@/lib/api/realtime' +import { useRealtimeSSE } from '@/lib/hooks/useRealtimeSSE' import { toast } from '@ciphera-net/ui' import { getAuthErrorMessage } from '@ciphera-net/ui' import { UserIcon } from '@ciphera-net/ui' @@ -27,28 +28,20 @@ export default function RealtimePage() { const siteId = params.id as string const [site, setSite] = useState(null) - const [visitors, setVisitors] = useState([]) + const { visitors } = useRealtimeSSE(siteId) const [selectedVisitor, setSelectedVisitor] = useState(null) const [sessionEvents, setSessionEvents] = useState([]) const [loading, setLoading] = useState(true) const [loadingEvents, setLoadingEvents] = useState(false) - // Load site info and initial visitors + // Load site info useEffect(() => { const init = async () => { try { - const [siteData, visitorsData] = await Promise.all([ - getSite(siteId), - getRealtimeVisitors(siteId) - ]) + const siteData = await getSite(siteId) setSite(siteData) - setVisitors(visitorsData || []) - // Select first visitor if available - if (visitorsData && visitorsData.length > 0) { - handleSelectVisitor(visitorsData[0]) - } } catch (error: unknown) { - toast.error(getAuthErrorMessage(error) || 'Failed to load realtime visitors') + toast.error(getAuthErrorMessage(error) || 'Failed to load site') } finally { setLoading(false) } @@ -56,27 +49,12 @@ export default function RealtimePage() { init() }, [siteId]) - // Poll for updates + // Auto-select the first visitor when the list populates and nothing is selected useEffect(() => { - const interval = setInterval(async () => { - try { - const data = await getRealtimeVisitors(siteId) - setVisitors(data || []) - - // Update selected visitor reference if they are still in the list - if (selectedVisitor) { - const updatedVisitor = data?.find(v => v.session_id === selectedVisitor.session_id) - if (updatedVisitor) { - // Don't overwrite the selectedVisitor state directly to avoid flickering details - // But we could update "last seen" indicators if we wanted - } - } - } catch (e) { - // Silent fail - } - }, 5000) - return () => clearInterval(interval) - }, [siteId, selectedVisitor]) + if (visitors.length > 0 && !selectedVisitor) { + handleSelectVisitor(visitors[0]) + } + }, [visitors]) // eslint-disable-line react-hooks/exhaustive-deps const handleSelectVisitor = async (visitor: Visitor) => { setSelectedVisitor(visitor) diff --git a/lib/hooks/useRealtimeSSE.ts b/lib/hooks/useRealtimeSSE.ts new file mode 100644 index 0000000..63e3b63 --- /dev/null +++ b/lib/hooks/useRealtimeSSE.ts @@ -0,0 +1,53 @@ +// * SSE hook for real-time visitor streaming. +// * Replaces 5-second polling with a persistent EventSource connection. +// * The backend broadcasts one DB query per site to all connected clients, +// * so 1,000 users on the same site share a single query instead of each +// * triggering their own. + +import { useEffect, useRef, useState, useCallback } from 'react' +import { API_URL } from '@/lib/api/client' +import type { Visitor } from '@/lib/api/realtime' + +interface UseRealtimeSSEReturn { + visitors: Visitor[] + connected: boolean +} + +export function useRealtimeSSE(siteId: string): UseRealtimeSSEReturn { + const [visitors, setVisitors] = useState([]) + const [connected, setConnected] = useState(false) + const esRef = useRef(null) + + // Stable callback so we don't recreate EventSource on every render + const handleMessage = useCallback((event: MessageEvent) => { + try { + const data = JSON.parse(event.data) + setVisitors(data.visitors || []) + } catch { + // Ignore malformed messages + } + }, []) + + useEffect(() => { + if (!siteId) return + + const url = `${API_URL}/api/v1/sites/${siteId}/realtime/stream` + const es = new EventSource(url, { withCredentials: true }) + esRef.current = es + + es.onopen = () => setConnected(true) + es.onmessage = handleMessage + es.onerror = () => { + setConnected(false) + // EventSource auto-reconnects with exponential backoff + } + + return () => { + es.close() + esRef.current = null + setConnected(false) + } + }, [siteId, handleMessage]) + + return { visitors, connected } +}