Real-Time Data Streaming in React: Server-Sent Events, WebSockets, and Resilient State Architecture
Modern web applications increasingly rely on real-time data delivery. Whether rendering live generative AI token streams, displaying high-frequency financial tickers, orchestrating collaborative multi-user editing, or updating operational dashboards, static request-response cycles are no longer sufficient.
However, streaming continuous data into React components introduces significant architectural hurdles. High-frequency message bursts can trigger "re-render storms" that drop frames and freeze the browser main thread. Naive connection management causes zombie sockets and memory leaks when components unmount. Furthermore, transient network disconnections require resilient reconnection logic with exponential backoff to avoid hammering backend infrastructure.
In this deep dive, we compare Server-Sent Events (SSE) and WebSockets, build reusable and production-grade React custom hooks for both protocols, explore stream buffering techniques, and demonstrate how to manage high-throughput external state using useSyncExternalStore without visual tearing.
Protocol Architecture: Server-Sent Events (SSE) vs. WebSockets
Choosing the right protocol determines application complexity, resource usage, and network resilience.
Server-Sent Events (SSE)
Server-Sent Events are built directly on standard HTTP. The client establishes a persistent connection via the EventSource API, and the server pushes text-based events using chunked transfer encoding (text/event-stream).
- Unidirectional: Server-to-client only. Client interactions continue using standard HTTP POST/PUT requests.
- Built-in Auto-Reconnection: The browser automatically handles reconnecting when the connection drops, sending the
Last-Event-IDheader so the server can resume without data loss. - HTTP/2 & HTTP/3 Multiplexing: Multiple SSE streams can share a single TCP connection, avoiding the six-connection-per-host limitation of HTTP/1.1.
- Ideal Use Cases: Generative AI token streaming (LLM chat), live notification feeds, read-only analytics, and sports scoreboards.
WebSockets
The WebSocket protocol upgrades an HTTP connection to a persistent, bidirectional, full-duplex TCP socket (ws:// or wss://).
- Bidirectional: Both client and server can transmit text or binary frames with minimal header overhead.
- Manual Lifecycle: No native reconnection mechanism; developers must manually implement heartbeats (ping/pong) and reconnection backoff algorithms.
- Ideal Use Cases: Multiplayer gaming, collaborative canvas editing (like Figma), peer-to-peer chat, and low-latency bidirectional trading terminals.
Building a Resilient Hook for Server-Sent Events
For unidirectional streaming—such as an AI assistant generating answers—SSE provides the cleanest integration. Here is a custom, memory-safe useServerSentEvents hook:
import { useState, useEffect, useRef } from 'react';
export interface SSEOptions {
onMessage?: (event: MessageEvent) => void;
onError?: (event: Event) => void;
withCredentials?: boolean;
}
export function useServerSentEvents(url: string | null, options: SSEOptions = {}) {
const [data, setData] = useState<string | null>(null);
const [status, setStatus] =<'connecting' | 'open' | 'closed'>('closed');
// Cache options to prevent unnecessary effect triggers
const optionsRef = useRef(options);
optionsRef.current = options;
useEffect(() => {
if (!url) {
setStatus('closed');
return;
}
setStatus('connecting');
const eventSource = new EventSource(url, {
withCredentials: optionsRef.current.withCredentials ?? false,
});
eventSource.onopen = () => {
setStatus('open');
};
eventSource.onmessage = (event) => {
setData(event.data);
optionsRef.current.onMessage?.(event);
};
eventSource.onerror = (event) => {
if (eventSource.readyState === EventSource.CLOSED) {
setStatus('closed');
} else {
setStatus('connecting');
}
optionsRef.current.onError?.(event);
};
// Critical Cleanup: Close connection when component unmounts or URL changes
return () => {
eventSource.close();
setStatus('closed');
};
}, [url]);
return { data, status };
}
By ensuring that eventSource.close() executes inside the effect cleanup, we eliminate hanging HTTP connections when users navigate away.
High-Throughput WebSockets: Heartbeats and Exponential Backoff
For bidirectional applications, raw WebSockets require robust connection safeguards:
- Heartbeat / Ping: Detect dead connections caused by silent network handovers.
- Exponential Backoff with Jitter: Prevent the "thundering herd" problem when a server restarts and thousands of clients reconnect simultaneously.
import { useEffect, useRef, useState, useCallback } from 'react';
interface WebSocketHookResult {
sendMessage: (message: string | object) => boolean;
status: 'connecting' | 'open' | 'closing' | 'closed';
lastMessage: MessageEvent | null;
}
export function useResilientWebSocket(url: string): WebSocketHookResult {
const [status, setStatus] = useState<'connecting' | 'open' | 'closing' | 'closed'>('connecting');
const [lastMessage, setLastMessage] = useState<MessageEvent | null>(null);
const wsRef = useRef<WebSocket | null>(null);
const reconnectAttempts = useRef(0);
const reconnectTimeoutId = useRef<NodeJS.Timeout | null>(null);
const connect = useCallback(() => {
if (!url) return;
setStatus('connecting');
const ws = new WebSocket(url);
wsRef.current = ws;
ws.onopen = () => {
setStatus('open');
reconnectAttempts.current = 0; // Reset counter on successful connection
};
ws.onmessage = (event) => {
setLastMessage(event);
};
ws.onclose = () => {
setStatus('closed');
wsRef.current = null;
// Exponential backoff: min(1000 * 2^attempt, 30000) + random jitter
const delay = Math.min(1000 * Math.pow(2, reconnectAttempts.current), 30000) + Math.random() * 1000;
reconnectAttempts.current += 1;
reconnectTimeoutId.current = setTimeout(() => {
connect();
}, delay);
};
ws.onerror = (err) => {
ws.close(); // Force trigger onclose for reconnection
};
}, [url]);
useEffect(() => {
connect();
return () => {
if (reconnectTimeoutId.current) clearTimeout(reconnectTimeoutId.current);
if (wsRef.current) {
wsRef.current.onclose = null; // Prevent reconnect on deliberate unmount
wsRef.current.close();
}
};
}, [connect]);
const sendMessage = useCallback((message: string | object) => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
const payload = typeof message === 'string' ? message : JSON.stringify(message);
wsRef.current.send(payload);
return true;
}
return false;
}, []);
return { sendMessage, status, lastMessage };
}
Mitigating Re-Render Storms: Throttling and useSyncExternalStore
In high-throughput environments—such as a crypto trading feed streaming 100 quotes per second—calling setState on every incoming frame overwhelms React, causing jank and input lag.
Pattern 1: Batching with requestAnimationFrame
Instead of setting state on every packet, buffer incoming messages in a ref and flush updates at display refresh rates (60Hz / 120Hz) using requestAnimationFrame:
import { useEffect, useRef, useState } from 'react';
export function useBufferedStream(lastMessage: MessageEvent | null) {
const [dataList, setDataList] = useState<string[]>([]);
const bufferRef = useRef<string[]>([]);
const rafId = useRef<number | null>(null);
useEffect(() => {
if (!lastMessage) return;
bufferRef.current.push(lastMessage.data);
if (rafId.current === null) {
rafId.current = requestAnimationFrame(() => {
setDataList((prev) => [...prev, ...bufferRef.current].slice(-100)); // keep last 100 items
bufferRef.current = [];
rafId.current = null;
});
}
}, [lastMessage]);
useEffect(() => {
return () => {
if (rafId.current !== null) cancelAnimationFrame(rafId.current);
};
}, []);
return dataList;
}
Pattern 2: External Store Subscription with useSyncExternalStore
When state lives outside the React component tree (e.g., in a global singleton WebSocket manager), useSyncExternalStore guarantees synchronous consistency and eliminates visual tearing during Concurrent React renders:
import { useSyncExternalStore } from 'react';
class LiveTickerStore {
private price: number = 0;
private listeners = new Set<() => void>();
constructor(wsUrl: string) {
const ws = new WebSocket(wsUrl);
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
this.price = data.price;
this.notify();
};
}
private notify() {
this.listeners.forEach((listener) => listener());
}
subscribe = (listener: () => void) => {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
};
getSnapshot = () => {
return this.price;
};
}
const tickerStore = new LiveTickerStore('wss://stream.example.com/quotes');
export function LivePriceWidget() {
const currentPrice = useSyncExternalStore(
tickerStore.subscribe,
tickerStore.getSnapshot
);
return <div className="price-tag">${currentPrice.toFixed(2)}</div>;
}
Architectural Best Practices
- Keep Connections Out of Re-rendering Components: Whenever multiple components require access to the same live stream, lift the connection into a React Context or an external store. Opening duplicate WebSockets for each component consumes unnecessary server sockets and battery life.
- Handle Intermittent Offline States: Use
navigator.onLineandwindow.addEventListener('online', ...)in tandem with WebSocket hooks to trigger immediate reconnection when mobile devices recover from airplane mode. - Avoid Stateful Buffer Bloat: In append-only streams (like log tails or LLM responses), limit array lengths with sliding windows (e.g., keeping only the latest 500 records) to prevent browser memory bloat over extended user sessions.
Conclusion
Real-time architectures in React require balancing network protocols with UI rendering efficiency. Server-Sent Events offer a lightweight, multiplexed, and auto-reconnecting solution for unidirectional streaming, while WebSockets excel in low-latency bidirectional environments. By pairing clean connection lifecycles with requestAnimationFrame batching and useSyncExternalStore, React applications can ingest high-velocity data streams while remaining completely responsive.