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.
This commit is contained in:
Usman Baig
2026-03-10 18:33:17 +01:00
parent d863004d5f
commit bcaa5c25f8
3 changed files with 65 additions and 33 deletions

View File

@@ -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

View File

@@ -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<Site | null>(null)
const [visitors, setVisitors] = useState<Visitor[]>([])
const { visitors } = useRealtimeSSE(siteId)
const [selectedVisitor, setSelectedVisitor] = useState<Visitor | null>(null)
const [sessionEvents, setSessionEvents] = useState<SessionEvent[]>([])
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)

View File

@@ -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<Visitor[]>([])
const [connected, setConnected] = useState(false)
const esRef = useRef<EventSource | null>(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 }
}