
Real-Time Updates in React with WebSockets
A dashboard that only refreshes when the user reloads the page feels broken the moment two people use it at once. One person moves a ticket to "Done," and the other keeps looking at the old column for the next ten minutes. Polling every few seconds fixes the staleness, but it wastes requests when nothing changes and still lags behind when something does.
WebSockets solve this by keeping a single, long-lived, two-way connection open between the browser and the server. The server can push a message the instant something changes, and the client can send messages back without the overhead of a new HTTP request each time.
This guide shows how to use WebSockets properly in React: where to open the connection, how to clean it up, how to reconnect after drops, how to route incoming messages into state, and how to combine pushed updates with a server-state library like TanStack Query.
How WebSockets Differ From HTTP
A normal HTTP request is a round trip: the client asks, the server answers, and the exchange is over. A WebSocket starts as an HTTP request with an Upgrade: websocket header. If the server agrees, the connection switches protocols and stays open. From then on, either side can send frames whenever it wants.
That gives you a few properties worth remembering:
- Low latency. There's no request setup per message, so updates arrive in milliseconds.
- Server push. The server doesn't need to wait for the client to ask.
- Stateful. The connection lives as long as both sides keep it open, which means you have to handle disconnects, reconnects, and cleanup yourself.
The browser API is small. You create a socket, listen for events, and call send():
const socket = new WebSocket("wss://example.com/live");
socket.addEventListener("open", () => {
socket.send(JSON.stringify({ type: "subscribe", channel: "orders" }));
});
socket.addEventListener("message", (event) => {
const data = JSON.parse(event.data);
console.log("Received", data);
});
socket.addEventListener("close", (event) => {
console.log("Closed", event.code, event.reason);
});
Use wss:// in production. It's the TLS-encrypted version, the same way https:// relates to http://, and many proxies and corporate networks block plain ws://.
A Minimal Server to Test Against
You need something to connect to. The ws package for Node.js is the simplest option:
npm install ws
// server.mjs
import { WebSocketServer } from "ws";
const wss = new WebSocketServer({ port: 8080 });
wss.on("connection", (socket) => {
socket.send(JSON.stringify({ type: "welcome", at: Date.now() }));
socket.on("message", (raw) => {
const message = JSON.parse(raw.toString());
// Broadcast every message to all connected clients
for (const client of wss.clients) {
if (client.readyState === client.OPEN) {
client.send(JSON.stringify({ type: "chat", ...message, at: Date.now() }));
}
}
});
});
console.log("Listening on ws://localhost:8080");
Run it with node server.mjs, and you have a broadcast server that echoes every message to every connected client.
Opening a Connection Inside a Component
The naive approach is to create the socket at the top of a component. That's wrong, because the component body runs on every render, and you'd open a new connection each time. The connection is a side effect, so it belongs in useEffect, with a cleanup function that closes it.
import { useEffect, useState } from "react";
type ChatMessage = { type: "chat"; user: string; text: string; at: number };
export function LiveChat() {
const [messages, setMessages] = useState<ChatMessage[]>([]);
useEffect(() => {
const socket = new WebSocket("ws://localhost:8080");
socket.addEventListener("message", (event) => {
const data = JSON.parse(event.data);
if (data.type === "chat") {
setMessages((prev) => [...prev, data]);
}
});
return () => socket.close();
}, []);
return (
<ul>
{messages.map((m) => (
<li key={m.at}>
<strong>{m.user}:</strong> {m.text}
</li>
))}
</ul>
);
}
Two details matter here. First, the functional update setMessages((prev) => ...) avoids a stale closure: the listener is registered once, so it would otherwise always see the initial empty array. Second, the cleanup closes the socket when the component unmounts. In development, Strict Mode mounts, unmounts, and mounts again, so you'll see one connection open and close immediately. That's expected and exactly why the cleanup must exist. If that behavior is new to you, read why your useEffect runs twice in React Strict Mode.
Building a Reusable useWebSocket Hook
Most apps need the socket in more than one place, plus a connection status and a way to send. Wrapping it in a custom hook keeps components clean.
import { useCallback, useEffect, useRef, useState } from "react";
type Status = "connecting" | "open" | "closed";
export function useWebSocket<T>(url: string, onMessage: (data: T) => void) {
const [status, setStatus] = useState<Status>("connecting");
const socketRef = useRef<WebSocket | null>(null);
const onMessageRef = useRef(onMessage);
// Always call the latest handler without reconnecting
useEffect(() => {
onMessageRef.current = onMessage;
});
useEffect(() => {
const socket = new WebSocket(url);
socketRef.current = socket;
setStatus("connecting");
socket.onopen = () => setStatus("open");
socket.onclose = () => setStatus("closed");
socket.onmessage = (event) => {
onMessageRef.current(JSON.parse(event.data) as T);
};
return () => {
socket.close();
socketRef.current = null;
};
}, [url]);
const send = useCallback((data: unknown) => {
const socket = socketRef.current;
if (socket?.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify(data));
}
}, []);
return { status, send };
}
The onMessageRef trick is important. If you put onMessage in the dependency array, every render that creates a new inline function would tear down and reopen the connection. Storing the latest handler in a ref lets the socket stay open while still calling up-to-date code. The useRef beyond DOM elements guide covers this pattern in more depth.
Using the hook is now straightforward:
import { useState, type FormEvent } from "react";
import { useWebSocket } from "./useWebSocket";
type ChatMessage = { type: "chat"; user: string; text: string; at: number };
export function ChatRoom({ user }: { user: string }) {
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [draft, setDraft] = useState("");
const { status, send } = useWebSocket<ChatMessage>("ws://localhost:8080", (msg) => {
if (msg.type === "chat") setMessages((prev) => [...prev, msg]);
});
function handleSubmit(e: FormEvent) {
e.preventDefault();
if (!draft.trim()) return;
send({ user, text: draft });
setDraft("");
}
return (
<section>
<p>Status: {status}</p>
<ul>
{messages.map((m) => (
<li key={`${m.at}-${m.user}`}>
<strong>{m.user}:</strong> {m.text}
</li>
))}
</ul>
<form onSubmit={handleSubmit}>
<input value={draft} onChange={(e) => setDraft(e.target.value)} />
<button disabled={status !== "open"}>Send</button>
</form>
</section>
);
}
Disabling the button while the socket isn't open stops users from typing messages that silently go nowhere.
Reconnecting After a Dropped Connection
Connections drop. Laptops sleep, phones switch from Wi-Fi to cellular, load balancers recycle servers. The browser fires close and then does nothing. Reconnection is your job.
The standard approach is exponential backoff with jitter: wait a short time after the first failure, double the wait after each consecutive failure up to a cap, and add some randomness so thousands of clients don't reconnect at the exact same moment after a server restart.
import { useCallback, useEffect, useRef, useState } from "react";
type Status = "connecting" | "open" | "reconnecting" | "closed";
export function useReconnectingWebSocket<T>(url: string, onMessage: (data: T) => void) {
const [status, setStatus] = useState<Status>("connecting");
const socketRef = useRef<WebSocket | null>(null);
const onMessageRef = useRef(onMessage);
useEffect(() => {
onMessageRef.current = onMessage;
});
useEffect(() => {
let attempt = 0;
let timer: ReturnType<typeof setTimeout> | undefined;
let disposed = false;
function connect() {
const socket = new WebSocket(url);
socketRef.current = socket;
socket.onopen = () => {
attempt = 0;
setStatus("open");
};
socket.onmessage = (event) => {
onMessageRef.current(JSON.parse(event.data) as T);
};
socket.onclose = () => {
if (disposed) return;
setStatus("reconnecting");
const base = Math.min(30_000, 500 * 2 ** attempt);
const delay = base / 2 + Math.random() * (base / 2);
attempt += 1;
timer = setTimeout(connect, delay);
};
}
connect();
return () => {
disposed = true;
clearTimeout(timer);
socketRef.current?.close();
setStatus("closed");
};
}, [url]);
const send = useCallback((data: unknown) => {
if (socketRef.current?.readyState === WebSocket.OPEN) {
socketRef.current.send(JSON.stringify(data));
}
}, []);
return { status, send };
}
The disposed flag is what separates "the network dropped" from "the component unmounted." Without it, unmounting would close the socket, trigger onclose, and schedule a reconnect for a component that no longer exists.
You can also listen to the browser's online event and reconnect immediately when the network returns, rather than waiting for the backoff timer. For most apps, the backoff alone is good enough.
Heartbeats
Some proxies silently kill idle connections without either side noticing. The socket looks open, but nothing arrives. A heartbeat fixes that: the client sends a small ping message every 20 to 30 seconds, and the server replies with pong. If no reply comes within a timeout, the client closes the socket itself, which triggers the normal reconnect path.
const HEARTBEAT_MS = 25_000;
function startHeartbeat(socket: WebSocket) {
let pongTimer: ReturnType<typeof setTimeout> | undefined;
const interval = setInterval(() => {
if (socket.readyState !== WebSocket.OPEN) return;
socket.send(JSON.stringify({ type: "ping" }));
pongTimer = setTimeout(() => socket.close(4000, "heartbeat timeout"), 5_000);
}, HEARTBEAT_MS);
function onPong() {
clearTimeout(pongTimer);
}
function stop() {
clearInterval(interval);
clearTimeout(pongTimer);
}
return { onPong, stop };
}
Call onPong() from your message handler when a pong arrives, and stop() in onclose and in the effect cleanup.
Sharing One Connection Across the App
Opening a socket in every component that needs live data multiplies connections fast. A cleaner design is one connection per app, created outside React, with components subscribing to the message types they care about.
// realtime.ts
type Listener = (payload: unknown) => void;
class RealtimeClient {
private socket: WebSocket | null = null;
private listeners = new Map<string, Set<Listener>>();
connect(url: string) {
if (this.socket) return;
this.socket = new WebSocket(url);
this.socket.onmessage = (event) => {
const { type, payload } = JSON.parse(event.data);
this.listeners.get(type)?.forEach((fn) => fn(payload));
};
}
subscribe(type: string, fn: Listener) {
if (!this.listeners.has(type)) this.listeners.set(type, new Set());
this.listeners.get(type)!.add(fn);
return () => {
this.listeners.get(type)?.delete(fn);
};
}
send(type: string, payload: unknown) {
if (this.socket?.readyState === WebSocket.OPEN) {
this.socket.send(JSON.stringify({ type, payload }));
}
}
}
export const realtime = new RealtimeClient();
import { useEffect } from "react";
import { realtime } from "./realtime";
export function useRealtimeEvent<T>(type: string, handler: (payload: T) => void) {
useEffect(() => {
return realtime.subscribe(type, (payload) => handler(payload as T));
}, [type, handler]);
}
Call realtime.connect(url) once at startup, for example in main.tsx. Each component subscribes to the event types it needs, and subscribe returns an unsubscribe function that doubles as the effect cleanup. Wrap the handler in useCallback at the call site, or the subscription will churn on each render.
For values that components read during render, such as "number of users online," useSyncExternalStore is a better fit than effects, because React can read the snapshot consistently during concurrent rendering.
Combining WebSockets With TanStack Query
In most real apps, the initial data comes from a REST or GraphQL request, and the socket only tells you when something changed. If you already manage server state with TanStack Query, you don't need to duplicate that data in useState. Let the socket update or invalidate the query cache.
There are two strategies:
- Invalidate. The server sends a lightweight event like "order 42 changed," and the client refetches. Simple and always correct, at the cost of an extra request.
- Patch the cache. The server sends the full updated entity, and the client writes it into the cache with
setQueryData. No extra request, but you must trust the payload shape.
import { useEffect } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
type Order = { id: number; status: string; total: number };
async function fetchOrders(): Promise<Order[]> {
const res = await fetch("/api/orders");
if (!res.ok) throw new Error("Failed to load orders");
return res.json();
}
export function useLiveOrders() {
const queryClient = useQueryClient();
useEffect(() => {
const socket = new WebSocket("wss://example.com/orders");
socket.onmessage = (event) => {
const message = JSON.parse(event.data);
if (message.type === "order.updated") {
queryClient.setQueryData<Order[]>(["orders"], (old) =>
old?.map((o) => (o.id === message.order.id ? message.order : o)),
);
}
if (message.type === "order.created") {
queryClient.invalidateQueries({ queryKey: ["orders"] });
}
};
return () => socket.close();
}, [queryClient]);
return useQuery({ queryKey: ["orders"], queryFn: fetchOrders });
}
When the socket reconnects after a drop, you may have missed events. A simple fix is to invalidate the relevant queries in onopen after a reconnect, so the cache catches up with whatever happened while you were offline.
Rendering Performance With High-Frequency Updates
A stock ticker or multiplayer cursor feed can send dozens of messages per second. Calling setState for each one makes React re-render dozens of times per second, which is wasteful when the screen refreshes at 60 frames per second at most.
Buffer incoming messages and flush them on an interval or animation frame:
import { useEffect, useRef, useState } from "react";
type Tick = { symbol: string; price: number };
export function usePriceFeed(url: string) {
const [prices, setPrices] = useState<Record<string, number>>({});
const buffer = useRef<Record<string, number>>({});
useEffect(() => {
const socket = new WebSocket(url);
socket.onmessage = (event) => {
const tick: Tick = JSON.parse(event.data);
buffer.current[tick.symbol] = tick.price;
};
const flush = setInterval(() => {
if (Object.keys(buffer.current).length === 0) return;
const pending = buffer.current;
buffer.current = {};
setPrices((prev) => ({ ...prev, ...pending }));
}, 250);
return () => {
clearInterval(flush);
socket.close();
};
}, [url]);
return prices;
}
Four renders per second is plenty for human eyes, and only the latest price per symbol survives each window. For lists of thousands of rows, combine this with memoized row components so only changed rows re-render.
Authentication and Security
The browser's WebSocket constructor doesn't let you set custom headers, so you can't send an Authorization: Bearer header the way you would with fetch. Common options:
- Cookies. If the socket is on the same site, the browser sends cookies with the upgrade request. The server validates the session there. This is the simplest option for same-origin apps.
- Short-lived ticket. The client calls an authenticated HTTP endpoint to get a one-time token, then connects with it in the query string, such as
wss://example.com/live?ticket=abc. The server validates and discards the ticket immediately. Keep these tokens short-lived because URLs end up in logs. - First-message auth. Open the socket, then send the token as the first message. The server refuses everything else until it's verified.
On the server, always check the Origin header during the upgrade to block cross-site WebSocket hijacking, and validate every incoming message as untrusted input.
When to Use Something Other Than Raw WebSockets
WebSockets are the right tool for bidirectional, low-latency traffic like chat, collaborative editing, and games. They aren't always the best fit:
- Server-Sent Events (SSE) work over plain HTTP, reconnect automatically through
EventSource, and are ideal when the server only pushes and the client rarely sends. Notifications and live feeds fit SSE well. - Libraries like Socket.IO add rooms, acknowledgments, and fallbacks, but require a matching server library and aren't compatible with plain WebSocket servers.
- Managed services such as Ably, Pusher, or Supabase Realtime handle scaling, presence, and reconnection for you.
- Polling is still fine when updates are rare and a delay of 30 seconds doesn't matter.
Common Mistakes With WebSockets in React
- Creating the socket in the component body. Every render opens a new connection. Create it in
useEffector outside React entirely. - Forgetting the cleanup. Without
socket.close()in the effect cleanup, connections leak every time a component unmounts or the URL changes. - Stale closures in message handlers. A handler registered once sees the state from the first render. Use functional updates or a ref to the latest handler.
- Putting callbacks in the effect's dependency array. An inline handler changes every render and reconnects the socket each time.
- Reconnecting after intentional unmounts. Track whether the effect was disposed so cleanup doesn't trigger a reconnect loop.
- Calling
send()before the socket is open. It throws anInvalidStateErrorwhile the socket is still connecting. CheckreadyStateor queue messages untilopen. - Trusting incoming messages. Parse defensively and validate the shape before writing it into state.
Frequently Asked Questions (FAQ) About WebSockets in React
Use Server-Sent Events when data flows mostly from server to client, such as notifications, progress updates, or live feeds. SSE runs over normal HTTP and reconnects automatically. Use WebSockets when the client also sends frequent messages, as in chat, collaborative editing, or multiplayer games.
React Strict Mode mounts, unmounts, and remounts components in development to surface missing cleanup. Your effect opens a socket, the cleanup closes it, and the second mount opens a new one. This doesn't happen in production builds, and it's harmless as long as your cleanup closes the socket.
The browser WebSocket API doesn't support custom headers. Rely on same-site cookies, pass a short-lived one-time ticket in the query string, or send the token as the first message after the connection opens. Never put a long-lived access token in the URL, because URLs are commonly logged.
Yes, and it's a good combination. Keep fetching the initial data with useQuery, and use socket messages to call setQueryData for full updates or invalidateQueries for change notifications. The query cache stays the single source of truth for components.
Usually one. Multiple connections to the same server waste resources and complicate reconnection. Create a single client outside the component tree and let components subscribe to the message types they need.
They're lost unless you handle it. On the client, either drop sends while disconnected or queue them and flush on reconnect. On the receiving side, refetch or invalidate data after a reconnect so you catch up on anything the server broadcast while you were offline.
Conclusion
Real-time updates in React come down to treating the WebSocket as a side effect with a clear lifecycle. Open it in an effect or a shared client, close it in cleanup, keep message handlers fresh with refs or functional updates, and reconnect with exponential backoff and a heartbeat so silent drops don't leave users staring at stale data.
From there, decide where the data lives. For simple widgets, local state is fine. For anything that also loads over HTTP, push updates into the TanStack Query cache so there's one source of truth. Start with a single shared connection, add buffering if updates arrive faster than you can render, and consider SSE or a managed service when your needs are one-way or your scale outgrows a single server.


