WebSocket Real-Time UI in React: Live Updates, Reconnect
Build WebSocket-powered real-time UIs in React with auto-reconnect logic, live update components, and Tailwind v4 styling — no bloated libraries required.
Why WebSockets Beat Polling for Live React UIs
Honestly, if you're still polling an endpoint every 3 seconds to show live data, you're leaving latency and battery life on the table. WebSockets keep a single TCP connection open and let the server push data the moment something changes. That's a fundamentally different model — and React is perfectly suited for it.
Polling works fine for dashboards that update every minute or so. But for real-time chat, live order tracking, collaborative editors, or stock tickers, 3-second polling feels broken to users. They notice. The 500–800ms round-trip delay on each poll adds up to a genuinely janky experience.
WebSockets drop connection overhead almost entirely once the handshake is done. Your server pushes a message, the browser receives it in milliseconds, and React re-renders the relevant component. The whole pipeline from event to pixel can comfortably sit under 50ms on a local network.
The browser WebSocket API has been stable since 2011 and is supported everywhere. You don't need a library to use it in React — though a well-designed custom hook makes it significantly less painful to manage across reconnects, cleanup, and component lifecycles.
Building a useWebSocket Hook With Auto-Reconnect
The core of any real-time React app is a reliable useWebSocket hook. You want it to handle connection state, incoming messages, and — this is the part people skip — exponential backoff reconnection when the socket drops.
Here's a production-ready hook. It uses a ref to track the socket instance (avoiding stale closures), a reconnect attempt counter, and useCallback to stabilize the message handler across renders. The reconnect delay starts at 1000ms and doubles each attempt up to 30 seconds.
import { useEffect, useRef, useState, useCallback } from 'react';
type WSStatus = 'connecting' | 'open' | 'closed' | 'error';
interface UseWebSocketOptions {
onMessage?: (event: MessageEvent) => void;
reconnectDelay?: number;
maxReconnectDelay?: number;
}
export function useWebSocket(url: string, options: UseWebSocketOptions = {}) {
const { onMessage, reconnectDelay = 1000, maxReconnectDelay = 30000 } = options;
const wsRef = useRef<WebSocket | null>(null);
const reconnectAttempt = useRef(0);
const reconnectTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const [status, setStatus] = useState<WSStatus>('connecting');
const connect = useCallback(() => {
const ws = new WebSocket(url);
wsRef.current = ws;
setStatus('connecting');
ws.onopen = () => {
setStatus('open');
reconnectAttempt.current = 0;
};
ws.onmessage = (event) => {
onMessage?.(event);
};
ws.onerror = () => setStatus('error');
ws.onclose = () => {
setStatus('closed');
const delay = Math.min(
reconnectDelay * 2 ** reconnectAttempt.current,
maxReconnectDelay
);
reconnectAttempt.current += 1;
reconnectTimer.current = setTimeout(connect, delay);
};
}, [url, onMessage, reconnectDelay, maxReconnectDelay]);
useEffect(() => {
connect();
return () => {
reconnectTimer.current && clearTimeout(reconnectTimer.current);
wsRef.current?.close();
};
}, [connect]);
const send = useCallback((data: string | ArrayBuffer) => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(data);
}
}, []);
return { status, send };
}The maxReconnectDelay cap at 30000ms is deliberate. Without it, after 10 failed attempts the delay would be over 17 minutes — useless. Thirty seconds is aggressive enough to recover from a brief server restart without hammering the server during an outage.
Displaying Live Updates: React State Without the Noise
Once you have messages flowing in, you need to put them somewhere. The naive approach is useState<Message[]> with a spread — but if messages arrive at 100/second, you're triggering 100 re-renders per second. That's a problem.
Use a message buffer pattern instead. Collect incoming messages in a ref, then flush to state at a controlled frame rate using requestAnimationFrame or a 50ms interval. For most live dashboards, users can't perceive updates faster than ~60ms anyway.
import { useRef, useState, useEffect, useCallback } from 'react';
import { useWebSocket } from './useWebSocket';
interface LiveMessage {
id: string;
text: string;
ts: number;
}
export function LiveFeed({ wsUrl }: { wsUrl: string }) {
const buffer = useRef<LiveMessage[]>([]);
const [messages, setMessages] = useState<LiveMessage[]>([]);
const handleMessage = useCallback((event: MessageEvent) => {
const data = JSON.parse(event.data) as LiveMessage;
buffer.current.push(data);
}, []);
const { status } = useWebSocket(wsUrl, { onMessage: handleMessage });
useEffect(() => {
const interval = setInterval(() => {
if (buffer.current.length === 0) return;
setMessages((prev) => {
const next = [...prev, ...buffer.current].slice(-100);
buffer.current = [];
return next;
});
}, 50);
return () => clearInterval(interval);
}, []);
return (
<div className="flex flex-col gap-2 p-4 bg-zinc-900 rounded-xl h-96 overflow-y-auto">
<div className="text-xs font-mono text-zinc-400 mb-2">
Status: <span className={status === 'open' ? 'text-emerald-400' : 'text-rose-400'}>{status}</span>
</div>
{messages.map((m) => (
<div key={m.id} className="text-sm text-zinc-100 border-l-2 border-zinc-700 pl-3">
{m.text}
</div>
))}
</div>
);
}The .slice(-100) keeps the last 100 messages in state — critical for long-running feeds. Without this, you'll silently build a memory leak as the messages array grows unbounded. If you need full history, that belongs in a virtualized list like react-window, not raw DOM nodes.
Connection Status Indicator With Tailwind v4
Users need to know when the connection drops. A subtle status dot is all it takes — no modal, no toast spam. You can pair this with React toast notifications for reconnect events if you want something more visible, but a persistent indicator is usually cleaner.
With Tailwind v4.0.2, the animate-pulse utility is still your friend for the connecting state. The trick is using bg-emerald-400, bg-amber-400, and bg-rose-400 as semantic colors that your users immediately understand without reading any text.
const STATUS_STYLES: Record<string, string> = {
connecting: 'bg-amber-400 animate-pulse',
open: 'bg-emerald-400',
closed: 'bg-rose-400',
error: 'bg-rose-600 animate-pulse',
};
export function ConnectionDot({ status }: { status: string }) {
return (
<span
title={`WebSocket: ${status}`}
className={`inline-block w-2.5 h-2.5 rounded-full ${STATUS_STYLES[status] ?? 'bg-zinc-600'}`}
/>
);
}The w-2.5 h-2.5 gives you exactly 10px — small enough not to distract, large enough to be clearly visible against a dark background. If you're building with a glassmorphism-style card (check out what is glassmorphism for context), rgba(255,255,255,0.15) backdrop cards work well as the container for this indicator.
Don't skip the title attribute. Screen reader users and power users hovering the dot will appreciate the explicit status string.
Handling JSON vs Binary Messages
Most WebSocket APIs you'll consume send JSON. But some — particularly ones pushing audio chunks, image tiles, or binary telemetry — send ArrayBuffer or Blob. You need to handle both without crashing the parse step.
A simple guard in your onMessage handler is all you need. Check event.data type before calling JSON.parse. For binary, you'll typically convert to a Uint8Array and pass it to whatever codec or processing function you have downstream.
const handleMessage = useCallback((event: MessageEvent) => {
if (typeof event.data === 'string') {
try {
const parsed = JSON.parse(event.data);
// handle JSON payload
} catch (e) {
console.warn('Non-JSON text frame received:', event.data);
}
} else if (event.data instanceof ArrayBuffer) {
const view = new Uint8Array(event.data);
// handle binary payload
} else if (event.data instanceof Blob) {
event.data.arrayBuffer().then((buf) => {
const view = new Uint8Array(buf);
// handle binary payload
});
}
}, []);One thing that trips people up: if your server sends a Blob and you try to access it synchronously, you get nothing. The .arrayBuffer() call is always async. Build your pipeline around that fact from the start.
For performance-sensitive binary streams, consider setting ws.binaryType = 'arraybuffer' immediately after opening the connection. This tells the browser to skip the Blob wrapper entirely and give you the raw buffer, saving one async hop.
Testing WebSocket Components Without a Real Server
Here's the thing: spinning up a real WebSocket server for every unit test is painful and slow. You want a mock that behaves like WebSocket but lives entirely in memory. The jest-websocket-mock package (v2.4.0+) is the standard approach for this in Jest environments.
For Vitest, you can use the same package — it works fine — or write a lightweight mock class that implements just send, close, and the event callbacks. Keep the mock in a __mocks__ folder and swap it in per-test with vi.mock. This lets you test reconnect logic, message buffering, and UI state transitions without any network.
A pattern worth noting: don't test the WebSocket protocol itself in your component tests. Test that your component responds correctly to messages. The difference is subtle but important — you're testing React state transitions, not TCP. The actual socket behavior belongs in integration tests, ideally against a real server in a CI container.
If you're building something with complex UI state driven by WebSocket data, pairing this with the React performance guide is worth your time. Frequent state updates from live data are one of the fastest ways to accidentally tank render performance.
Server-Sent Events vs WebSockets: When to Pick Each
WebSockets are bidirectional — client sends, server sends, both at any time. SSE (Server-Sent Events) is one-direction: server pushes to client only. If your use case is genuinely one-directional — think live logs, notification feeds, real-time dashboards — SSE is often simpler and works better through HTTP/2 proxies.
Why does this matter practically? Many corporate proxies and CDNs handle SSE without issues because it's just HTTP. WebSocket upgrades can get blocked or cause strange behavior behind certain load balancers. If you're deploying to environments you don't fully control, SSE's HTTP compatibility is a real advantage.
That said, for anything collaborative — chat, multiplayer, shared cursors, live forms — you need bidirectional communication and WebSockets win clearly. You can technically simulate client-to-server messaging with SSE + HTTP POST, but it's awkward. Use the right tool. If you're building multi-user shared UI, WebSockets are what you want.
What about WebTransport? It's the upcoming replacement for both, offering multiplexed streams over HTTP/3 with lower latency than WebSockets. Browser support is still maturing as of late 2026, so it's not production-ready for most apps. Keep an eye on it — the API is genuinely cleaner.
Putting It Together: A Live Dashboard Component
Let's wire everything into a component you'd actually ship. This example shows a live metrics dashboard pulling server stats via WebSocket — CPU, memory, request rate. The useWebSocket hook handles connectivity, the 50ms buffer flush handles render frequency, and Tailwind handles the visual.
For the layout, a 3-column grid with 8px gap (gap-2 in Tailwind) works well for metric cards. Each card gets a bottom border pulse animation when its value changes — a small touch that makes the data feel genuinely live without being distracting. If you want to add a theme toggle for dark/light switching on this dashboard, the Tailwind dark mode variants slot in cleanly.
The reconnect state is surfaced as a banner rather than hiding it. When you're watching a production metrics dashboard and the connection drops, you absolutely need to know — not discover it 10 minutes later when you wonder why the graphs stopped moving. Honest connection state display is non-negotiable for any live data UI.
From here, you can extend this pattern to any real-time use case: collaborative form editing (see React Hook Form for the form layer), live auction UIs, sports scoreboards, IoT sensor displays. The hook is generic, the buffering pattern is universal, and the status indicator slots into any design system.
FAQ
It can be annoying in dev. Strict Mode mounts, unmounts, then remounts every component in development. Your cleanup function in useEffect should close the socket and clear any reconnect timers — if it does, the second mount starts fresh. In production Strict Mode has no effect, so you won't see the double-connect behavior in your deployed app.
You can't set custom HTTP headers on the WebSocket handshake in the browser — the API doesn't expose that. The two standard approaches are: pass the token as a query param in the URL (e.g., wss://api.example.com/ws?token=abc123), or send it as the first message after the socket opens. Query params work fine for most cases; the first-message approach avoids tokens appearing in server logs.
Yes, if you're creating the socket inside the component's useEffect. The cleanup function closes it on unmount. If you need a persistent connection that survives route changes, lift the socket into a React context or Zustand store that lives outside the component tree. The useWebSocket hook shown in this article can be adapted to work inside a context provider.
Use the buffer-and-flush pattern from the LiveFeed example above. Accumulate messages in a ref (which doesn't trigger renders), then flush to state at a capped rate — 50ms intervals work well for most UIs. Also cap the state array size with .slice(-N) so it doesn't grow without bound over long sessions.
Yes, but only in Client Components — anything that creates a WebSocket must have 'use client' at the top. You can't initiate WebSocket connections in Server Components or during server-side rendering. The connection always starts on the client after hydration. For the server side, Next.js doesn't natively support WebSocket servers; you need a separate WebSocket server process or a service like Pusher, Ably, or Supabase Realtime.
Exponential backoff with jitter. Start at 1000ms, double each attempt, cap at 30000ms. Add jitter by multiplying the delay by a random value between 0.8 and 1.2 — this prevents all clients reconnecting at exactly the same moment after a server restart. The hook in this article implements the backoff without jitter; add * (0.8 + Math.random() * 0.4) to the delay calculation for production use.