21 KiB
21 KiB
Real-Time Dashboard Updates
Table of Contents
- Update Strategy Comparison
- Server-Sent Events (SSE)
- WebSocket Implementation
- Polling Patterns
- Update Coordination
- Error Handling & Reconnection
- Performance Optimization
Update Strategy Comparison
Decision Matrix
| Method | Use Case | Pros | Cons | Best For |
|---|---|---|---|---|
| SSE | Server → Client updates | Simple, auto-reconnect, HTTP-based | Unidirectional only | Dashboard metrics, notifications |
| WebSocket | Bidirectional communication | Real-time, low latency, bidirectional | Complex setup, stateful | Collaborative editing, chat |
| Long Polling | Fallback for older browsers | Works everywhere, simple | Higher latency, resource intensive | Legacy support |
| Smart Polling | Periodic updates | Simple, predictable, cacheable | Not real-time, bandwidth usage | Non-critical updates |
Choosing the Right Method
function selectUpdateStrategy(requirements: Requirements): UpdateStrategy {
// Need bidirectional communication?
if (requirements.bidirectional) {
return 'websocket';
}
// Need guaranteed real-time updates?
if (requirements.realTime && requirements.lowLatency) {
return requirements.serverPush ? 'sse' : 'websocket';
}
// Browser compatibility concerns?
if (requirements.legacySupport) {
return 'polling';
}
// Default recommendation for dashboards
return 'sse';
}
Server-Sent Events (SSE)
Basic SSE Implementation
function useSSE(endpoint: string) {
const [data, setData] = useState(null);
const [error, setError] = useState(null);
const [status, setStatus] = useState<'connecting' | 'open' | 'closed'>('connecting');
useEffect(() => {
const eventSource = new EventSource(endpoint);
eventSource.onopen = () => {
setStatus('open');
setError(null);
};
eventSource.onmessage = (event) => {
try {
const parsedData = JSON.parse(event.data);
setData(parsedData);
} catch (err) {
console.error('Failed to parse SSE data:', err);
}
};
eventSource.onerror = (err) => {
setError(err);
setStatus('closed');
// EventSource will auto-reconnect
// But we can handle the error state
console.error('SSE connection error:', err);
};
return () => {
eventSource.close();
setStatus('closed');
};
}, [endpoint]);
return { data, error, status };
}
SSE with Multiple Event Types
function useMultiEventSSE(endpoint: string) {
const [metrics, setMetrics] = useState({});
const [alerts, setAlerts] = useState([]);
const [status, setStatus] = useState({});
useEffect(() => {
const eventSource = new EventSource(endpoint);
// Different event types
eventSource.addEventListener('metrics', (event) => {
const data = JSON.parse(event.data);
setMetrics(prev => ({ ...prev, ...data }));
});
eventSource.addEventListener('alert', (event) => {
const alert = JSON.parse(event.data);
setAlerts(prev => [...prev, alert]);
});
eventSource.addEventListener('status', (event) => {
const statusUpdate = JSON.parse(event.data);
setStatus(statusUpdate);
});
// Heartbeat to detect connection issues
eventSource.addEventListener('heartbeat', () => {
console.log('Heartbeat received');
});
return () => eventSource.close();
}, [endpoint]);
return { metrics, alerts, status };
}
SSE Server Implementation (Node.js)
// Server-side SSE endpoint
app.get('/api/dashboard/stream', (req, res) => {
// Set SSE headers
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
'Access-Control-Allow-Origin': '*'
});
// Send initial data
res.write(`data: ${JSON.stringify({ type: 'init', timestamp: Date.now() })}\n\n`);
// Send updates every 5 seconds
const interval = setInterval(() => {
const metrics = generateMetrics();
res.write(`event: metrics\ndata: ${JSON.stringify(metrics)}\n\n`);
}, 5000);
// Send heartbeat every 30 seconds
const heartbeat = setInterval(() => {
res.write(':heartbeat\n\n');
}, 30000);
// Clean up on client disconnect
req.on('close', () => {
clearInterval(interval);
clearInterval(heartbeat);
});
});
WebSocket Implementation
WebSocket Hook with Reconnection
function useWebSocket(url: string, options = {}) {
const [socket, setSocket] = useState<WebSocket | null>(null);
const [lastMessage, setLastMessage] = useState(null);
const [readyState, setReadyState] = useState(WebSocket.CONNECTING);
const reconnectAttempts = useRef(0);
const maxReconnectAttempts = options.maxReconnectAttempts || 5;
const reconnectInterval = options.reconnectInterval || 3000;
const connect = useCallback(() => {
try {
const ws = new WebSocket(url);
ws.onopen = () => {
console.log('WebSocket connected');
setReadyState(WebSocket.OPEN);
reconnectAttempts.current = 0;
};
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
setLastMessage(data);
// Handle different message types
switch (data.type) {
case 'metrics':
handleMetricsUpdate(data.payload);
break;
case 'alert':
handleAlert(data.payload);
break;
default:
console.log('Unknown message type:', data.type);
}
};
ws.onclose = () => {
setReadyState(WebSocket.CLOSED);
// Attempt reconnection
if (reconnectAttempts.current < maxReconnectAttempts) {
reconnectAttempts.current++;
setTimeout(connect, reconnectInterval);
}
};
ws.onerror = (error) => {
console.error('WebSocket error:', error);
setReadyState(WebSocket.CLOSED);
};
setSocket(ws);
} catch (error) {
console.error('Failed to create WebSocket:', error);
}
}, [url]);
useEffect(() => {
connect();
return () => {
if (socket) {
socket.close();
}
};
}, []);
const sendMessage = useCallback((message: any) => {
if (socket && socket.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify(message));
} else {
console.warn('WebSocket is not connected');
}
}, [socket]);
return {
lastMessage,
sendMessage,
readyState,
reconnect: connect
};
}
Bidirectional Dashboard Communication
function InteractiveDashboard() {
const { lastMessage, sendMessage, readyState } = useWebSocket(
'ws://localhost:3000/dashboard'
);
// Send filter changes to server
const handleFilterChange = (filters) => {
sendMessage({
type: 'UPDATE_FILTERS',
payload: filters
});
};
// Request specific data
const requestWidgetData = (widgetId) => {
sendMessage({
type: 'REQUEST_WIDGET_DATA',
payload: { widgetId }
});
};
// Subscribe to specific metrics
const subscribeToMetric = (metricName) => {
sendMessage({
type: 'SUBSCRIBE',
payload: { metric: metricName }
});
};
return (
<div>
{readyState === WebSocket.OPEN ? (
<span className="status-indicator online">Connected</span>
) : (
<span className="status-indicator offline">Disconnected</span>
)}
<FilterPanel onChange={handleFilterChange} />
{/* Dashboard widgets */}
</div>
);
}
Polling Patterns
Smart Polling with Exponential Backoff
function useSmartPolling(
fetchFunction: () => Promise<any>,
initialInterval = 5000
) {
const [data, setData] = useState(null);
const [error, setError] = useState(null);
const [isPolling, setIsPolling] = useState(true);
const intervalRef = useRef(initialInterval);
const errorCount = useRef(0);
useEffect(() => {
if (!isPolling) return;
const poll = async () => {
try {
const result = await fetchFunction();
setData(result);
setError(null);
// Reset interval on success
intervalRef.current = initialInterval;
errorCount.current = 0;
} catch (err) {
setError(err);
errorCount.current++;
// Exponential backoff on errors
intervalRef.current = Math.min(
initialInterval * Math.pow(2, errorCount.current),
60000 // Max 1 minute
);
}
};
// Initial fetch
poll();
// Set up polling interval
const timer = setInterval(poll, intervalRef.current);
return () => clearInterval(timer);
}, [fetchFunction, initialInterval, isPolling]);
return {
data,
error,
isPolling,
startPolling: () => setIsPolling(true),
stopPolling: () => setIsPolling(false)
};
}
Adaptive Polling Based on Activity
function useAdaptivePolling(endpoint: string) {
const [data, setData] = useState(null);
const [pollingRate, setPollingRate] = useState(30000); // Start at 30s
const lastActivityRef = useRef(Date.now());
useEffect(() => {
// Track user activity
const handleActivity = () => {
lastActivityRef.current = Date.now();
// Increase polling rate when user is active
setPollingRate(5000);
};
window.addEventListener('mousemove', handleActivity);
window.addEventListener('keypress', handleActivity);
// Check for inactivity and slow down polling
const inactivityTimer = setInterval(() => {
const timeSinceActivity = Date.now() - lastActivityRef.current;
if (timeSinceActivity > 60000) {
// No activity for 1 minute, slow polling
setPollingRate(60000);
} else if (timeSinceActivity > 30000) {
// No activity for 30 seconds, medium polling
setPollingRate(30000);
}
}, 10000);
return () => {
window.removeEventListener('mousemove', handleActivity);
window.removeEventListener('keypress', handleActivity);
clearInterval(inactivityTimer);
};
}, []);
useEffect(() => {
const fetchData = async () => {
try {
const response = await fetch(endpoint);
const result = await response.json();
setData(result);
} catch (error) {
console.error('Polling error:', error);
}
};
fetchData();
const timer = setInterval(fetchData, pollingRate);
return () => clearInterval(timer);
}, [endpoint, pollingRate]);
return { data, pollingRate };
}
Long Polling Implementation
function useLongPolling(endpoint: string) {
const [data, setData] = useState(null);
const [isPolling, setIsPolling] = useState(true);
const abortControllerRef = useRef<AbortController>();
useEffect(() => {
if (!isPolling) return;
const poll = async () => {
abortControllerRef.current = new AbortController();
try {
const response = await fetch(endpoint, {
signal: abortControllerRef.current.signal,
headers: {
'X-Long-Poll-Timeout': '30000' // 30 second timeout
}
});
if (response.ok) {
const result = await response.json();
setData(result);
}
// Immediately poll again
if (isPolling) {
poll();
}
} catch (error) {
if (error.name !== 'AbortError') {
console.error('Long polling error:', error);
// Retry after delay
setTimeout(() => {
if (isPolling) poll();
}, 5000);
}
}
};
poll();
return () => {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
};
}, [endpoint, isPolling]);
return {
data,
stop: () => setIsPolling(false),
start: () => setIsPolling(true)
};
}
Update Coordination
Centralized Update Manager
class DashboardUpdateManager {
private widgets = new Map<string, WidgetSubscriber>();
private updateSource: EventSource | WebSocket | null = null;
initialize(strategy: 'sse' | 'websocket', endpoint: string) {
if (strategy === 'sse') {
this.initializeSSE(endpoint);
} else {
this.initializeWebSocket(endpoint);
}
}
private initializeSSE(endpoint: string) {
this.updateSource = new EventSource(endpoint);
this.updateSource.onmessage = (event) => {
this.handleUpdate(JSON.parse(event.data));
};
}
private initializeWebSocket(endpoint: string) {
this.updateSource = new WebSocket(endpoint);
this.updateSource.onmessage = (event) => {
this.handleUpdate(JSON.parse(event.data));
};
}
private handleUpdate(update: DashboardUpdate) {
// Route updates to appropriate widgets
if (update.widgetId) {
// Update specific widget
const subscriber = this.widgets.get(update.widgetId);
if (subscriber) {
subscriber.onUpdate(update.data);
}
} else if (update.type === 'broadcast') {
// Update all widgets
this.widgets.forEach(subscriber => {
subscriber.onUpdate(update.data);
});
}
}
subscribeWidget(widgetId: string, subscriber: WidgetSubscriber) {
this.widgets.set(widgetId, subscriber);
}
unsubscribeWidget(widgetId: string) {
this.widgets.delete(widgetId);
}
cleanup() {
if (this.updateSource) {
if (this.updateSource instanceof EventSource) {
this.updateSource.close();
} else {
this.updateSource.close();
}
}
}
}
// Usage in component
function Widget({ id, type }) {
const updateManager = useContext(UpdateManagerContext);
useEffect(() => {
const subscriber = {
onUpdate: (data) => {
// Handle widget update
console.log(`Widget ${id} received update:`, data);
}
};
updateManager.subscribeWidget(id, subscriber);
return () => {
updateManager.unsubscribeWidget(id);
};
}, [id, updateManager]);
return <div>Widget content</div>;
}
Differential Updates
function useDifferentialUpdates(initialData) {
const [data, setData] = useState(initialData);
const [updateQueue, setUpdateQueue] = useState([]);
const applyDiff = useCallback((diff) => {
setData(prevData => {
// Apply JSON Patch or custom diff format
if (diff.op === 'replace') {
return {
...prevData,
[diff.path]: diff.value
};
} else if (diff.op === 'add') {
return {
...prevData,
[diff.path]: [...(prevData[diff.path] || []), diff.value]
};
} else if (diff.op === 'remove') {
const newData = { ...prevData };
delete newData[diff.path];
return newData;
}
return prevData;
});
}, []);
// Process update queue
useEffect(() => {
if (updateQueue.length > 0) {
const diff = updateQueue[0];
applyDiff(diff);
setUpdateQueue(prev => prev.slice(1));
}
}, [updateQueue, applyDiff]);
return {
data,
queueUpdate: (diff) => setUpdateQueue(prev => [...prev, diff])
};
}
Error Handling & Reconnection
Robust Connection Management
function useRobustConnection(endpoint: string, options = {}) {
const [status, setStatus] = useState<ConnectionStatus>('disconnected');
const [error, setError] = useState<Error | null>(null);
const [retryCount, setRetryCount] = useState(0);
const maxRetries = options.maxRetries || 5;
const retryDelay = options.retryDelay || 3000;
const connect = useCallback(() => {
setStatus('connecting');
setError(null);
const source = new EventSource(endpoint);
source.onopen = () => {
setStatus('connected');
setRetryCount(0);
console.log('Connection established');
};
source.onerror = (event) => {
setStatus('error');
setError(new Error('Connection failed'));
if (retryCount < maxRetries) {
setStatus('reconnecting');
setTimeout(() => {
setRetryCount(prev => prev + 1);
source.close();
connect();
}, retryDelay * Math.pow(2, retryCount)); // Exponential backoff
} else {
setStatus('failed');
console.error('Max reconnection attempts reached');
}
};
return source;
}, [endpoint, retryCount, maxRetries, retryDelay]);
useEffect(() => {
const source = connect();
return () => source.close();
}, []);
return { status, error, retryCount };
}
Circuit Breaker Pattern
class CircuitBreaker {
private failureCount = 0;
private lastFailureTime: number | null = null;
private state: 'closed' | 'open' | 'half-open' = 'closed';
constructor(
private threshold = 5,
private timeout = 60000 // 1 minute
) {}
async execute<T>(operation: () => Promise<T>): Promise<T> {
if (this.state === 'open') {
const now = Date.now();
if (now - this.lastFailureTime! > this.timeout) {
this.state = 'half-open';
} else {
throw new Error('Circuit breaker is OPEN');
}
}
try {
const result = await operation();
if (this.state === 'half-open') {
this.state = 'closed';
this.failureCount = 0;
}
return result;
} catch (error) {
this.failureCount++;
this.lastFailureTime = Date.now();
if (this.failureCount >= this.threshold) {
this.state = 'open';
console.error('Circuit breaker opened due to failures');
}
throw error;
}
}
reset() {
this.failureCount = 0;
this.state = 'closed';
this.lastFailureTime = null;
}
}
// Usage
const circuitBreaker = new CircuitBreaker();
function useFetchWithCircuitBreaker(endpoint: string) {
const [data, setData] = useState(null);
const [error, setError] = useState(null);
const fetchData = async () => {
try {
const result = await circuitBreaker.execute(async () => {
const response = await fetch(endpoint);
if (!response.ok) throw new Error('Fetch failed');
return response.json();
});
setData(result);
setError(null);
} catch (err) {
setError(err);
console.error('Fetch with circuit breaker failed:', err);
}
};
return { data, error, retry: fetchData };
}
Performance Optimization
Update Batching
function useBatchedUpdates(flushInterval = 100) {
const [updates, setUpdates] = useState([]);
const batchRef = useRef([]);
const timerRef = useRef<NodeJS.Timeout>();
const addUpdate = useCallback((update) => {
batchRef.current.push(update);
// Clear existing timer
if (timerRef.current) {
clearTimeout(timerRef.current);
}
// Set new timer to flush batch
timerRef.current = setTimeout(() => {
if (batchRef.current.length > 0) {
setUpdates(batchRef.current);
batchRef.current = [];
}
}, flushInterval);
}, [flushInterval]);
// Process batched updates
useEffect(() => {
if (updates.length > 0) {
console.log(`Processing ${updates.length} batched updates`);
// Apply all updates at once
applyBatchedUpdates(updates);
setUpdates([]);
}
}, [updates]);
return { addUpdate };
}
Selective Widget Updates
function OptimizedDashboard() {
const [widgetData, setWidgetData] = useState({});
const [updateMetadata, setUpdateMetadata] = useState({});
const handleUpdate = useCallback((update) => {
const { widgetId, data, timestamp } = update;
// Check if update is newer than current data
if (updateMetadata[widgetId]?.timestamp >= timestamp) {
console.log(`Skipping stale update for widget ${widgetId}`);
return;
}
// Only update the specific widget
setWidgetData(prev => ({
...prev,
[widgetId]: data
}));
setUpdateMetadata(prev => ({
...prev,
[widgetId]: { timestamp, updateCount: (prev[widgetId]?.updateCount || 0) + 1 }
}));
}, [updateMetadata]);
return (
<div>
{Object.entries(widgetData).map(([id, data]) => (
<MemoizedWidget
key={id}
id={id}
data={data}
metadata={updateMetadata[id]}
/>
))}
</div>
);
}
const MemoizedWidget = React.memo(({ id, data, metadata }) => {
return (
<div className="widget">
<h3>Widget {id}</h3>
<pre>{JSON.stringify(data, null, 2)}</pre>
{metadata && (
<div className="metadata">
Updates: {metadata.updateCount}
</div>
)}
</div>
);
}, (prevProps, nextProps) => {
// Only re-render if data actually changed
return JSON.stringify(prevProps.data) === JSON.stringify(nextProps.data);
});