When we need "real-time" data in a frontend application, WebSocket is usually the first thing that comes to mind.
But real-time does not automatically mean WebSocket.
Sometimes polling is enough. Sometimes Server-Sent Events (SSE) are a much simpler solution. And sometimes you really do need a persistent, bidirectional connection.
From a frontend perspective, the useful question is not:
Which one is more powerful?
It is:
How does data need to flow between my frontend and backend?
Let's look at Polling, SSE, and WebSocket from that perspective.
The mental model
Polling
The client keeps asking the server for new data.
Client → Server: Anything new?
Server → Client: Not yet.
Client → Server: Anything new?
Server → Client: Yes.
Server-Sent Events
The client opens a connection once, and the server keeps pushing updates.
Client → Server: Open stream
Server → Client: Update
Server → Client: Update
Server → Client: Update
WebSocket
Both sides can send messages whenever they want.
Client ⇄ Server
That difference already answers a large part of the decision.
1. Polling
Polling is the simplest approach.
The frontend sends a normal HTTP request every few seconds to check whether something has changed.
Imagine the user starts a background job and the backend gives us:
GET /api/jobs/42
which returns:
{
"id": "42",
"status": "processing",
"progress": 60
}
A few seconds later:
{
"id": "42",
"status": "completed",
"progress": 100
}
In React, we can simply keep requesting the endpoint until the job finishes.
useEffect(() => {
let timeoutId: ReturnType<typeof setTimeout>;
let cancelled = false;
async function poll() {
try {
const response = await fetch(`/api/jobs/${jobId}`);
if (!response.ok) {
throw new Error("Failed to fetch job");
}
const job = await response.json();
if (cancelled) return;
setJob(job);
if (
job.status === "queued" ||
job.status === "processing"
) {
timeoutId = setTimeout(poll, 2000);
}
} catch (error) {
if (!cancelled) {
console.error("Polling failed:", error);
}
}
}
poll();
return () => {
cancelled = true;
clearTimeout(timeoutId);
};
}, [jobId]);
I prefer setTimeout here instead of setInterval because the next request is scheduled after the previous one finishes, so slow requests do not start piling up.
In production, you may also want to handle things like request cancellation with AbortController, retries, and backoff.
A server-state library can make polling much cleaner.
For example, TanStack Query supports polling through refetchInterval:
useQuery({
queryKey: ["job", jobId],
queryFn: () => fetchJob(jobId),
refetchInterval: (query) => {
const status = query.state.data?.status;
return status === "completed" ||
status === "failed"
? false
: 2000;
},
});
The important point is that TanStack Query is not a real-time transport here.
It is still making normal HTTP requests repeatedly.
When should you use polling?
Polling is a good fit when:
- updates are relatively infrequent
- a few seconds of delay are acceptable
- the backend already exposes a normal HTTP endpoint
- simplicity is more important than instant updates
Common examples:
- background jobs
- payment status
- export generation
- deployment status
- dashboards refreshing every few seconds
The downside becomes more noticeable at scale.
If thousands of users poll every two seconds, many of those requests may return exactly the same data.
You are still paying for the request even when nothing has changed.
2. Server-Sent Events (SSE)
SSE changes the model.
Instead of repeatedly asking for updates, the frontend opens an HTTP connection and the server keeps that response open.
For example, the backend might expose:
GET /api/orders/42/events
with:
Content-Type: text/event-stream
and send events like:
event: order.updated
data: {"id":"42","status":"processing"}
event: order.updated
data: {"id":"42","status":"shipped"}
The browser already provides a native API for consuming this:
useEffect(() => {
const source = new EventSource(
`/api/orders/${orderId}/events`
);
source.addEventListener(
"order.updated",
(event) => {
const updatedOrder = JSON.parse(
(event as MessageEvent).data
);
setOrder(updatedOrder);
}
);
return () => source.close();
}, [orderId]);
Unlike polling, the server can push an update immediately when something changes.
And unlike the native WebSocket API, EventSource automatically attempts to reconnect when the connection is interrupted.
But SSE is one-way, right?
Yes.
The stream itself is:
Server → Client
But your application can still send data with normal HTTP requests.
For example:
GET /api/chat/events
↓
Receive messages through SSE
POST /api/chat/messages
↑
Send messages through HTTP
So HTTP + SSE can be a perfectly valid architecture.
The client does not need to send everything through the same persistent connection.
One thing to know about EventSource
The native EventSource API is intentionally simple.
For example, it does not let you attach arbitrary request headers such as:
Authorization: Bearer ...
Its constructor essentially gives you a URL and the option to include credentials.
If you need more control over the request — such as custom headers, a request body, or custom retry behavior — a fetch-based SSE client such as @microsoft/fetch-event-source can be useful.
When should you use SSE?
SSE is a strong choice when the frontend mostly needs to listen.
Examples:
- notifications
- live dashboards
- order status
- log streaming
- progress updates
- live feeds
- AI response streaming
A useful question is:
Does my frontend mostly receive updates from the server?
If yes, SSE is worth considering before WebSocket.
3. WebSocket
Now imagine something more interactive.
For example, a chat application:
Client → Server: typing.started
Server → Client: user.online
Client → Server: message.send
Server → Client: message.created
Both sides need to communicate frequently.
This is where WebSocket fits naturally.
The backend might expose:
wss://api.example.com/realtime
and send messages such as:
{
"type": "message.created",
"payload": {
"id": "123",
"text": "Hello",
"userId": "42"
}
}
From the frontend, the browser already provides a native API.
In React, we can keep the connection in a ref so we can also use it when we need to send messages:
const socketRef = useRef<WebSocket | null>(null);
useEffect(() => {
const socket = new WebSocket(
"wss://api.example.com/realtime"
);
socketRef.current = socket;
socket.onopen = () => {
console.log("Connected");
};
socket.onmessage = (event) => {
const message = JSON.parse(event.data);
console.log(message);
};
socket.onclose = () => {
console.log("Disconnected");
};
return () => {
socket.close();
socketRef.current = null;
};
}, []);
Then we can send data over the same connection:
function sendMessage(text: string) {
const socket = socketRef.current;
if (socket?.readyState !== WebSocket.OPEN) {
return;
}
socket.send(
JSON.stringify({
type: "message.send",
payload: {
text,
},
})
);
}
Checking readyState matters because the connection is established asynchronously. We should not assume the socket is ready immediately after calling new WebSocket().
That's the biggest difference.
With WebSocket, communication is truly bidirectional:
Client ⇄ Server
But opening a WebSocket is the easy part.
In production, you may also need to handle things like:
- reconnecting
- authentication
- heartbeats
- re-subscribing after reconnect
- duplicate or missed messages
- connection state
- message ordering
- backpressure
Authentication also deserves some attention.
Like EventSource, the native browser WebSocket constructor does not provide a general-purpose way to attach arbitrary headers such as:
Authorization: Bearer ...
Depending on the architecture, authentication may instead use cookies, short-lived connection tokens, URL parameters, WebSocket subprotocols, or an application-level authentication message.
For simple applications, the native API may be enough.
For larger React applications, libraries like react-use-websocket can help manage the connection lifecycle.
What about Socket.IO?
Socket.IO often gets mentioned together with WebSocket, but they are not the same thing.
WebSocket is a protocol and browser API.
Socket.IO is a higher-level client/server library that adds features such as:
- automatic reconnection
- named events
- acknowledgements
- rooms
- packet buffering
- connection state recovery
- transport management
For example:
const socket = io("https://api.example.com");
socket.on("message.created", (message) => {
console.log(message);
});
socket.emit("message.send", {
text: "Hello",
});
One important detail:
A Socket.IO client expects a Socket.IO server.
You cannot connect socket.io-client directly to an arbitrary plain WebSocket backend.
Quick comparison
A practical way to choose
Imagine you're building an order tracking page:
created
↓
paid
↓
processing
↓
shipped
↓
delivered
If checking every 30 seconds is fine:
Polling is probably enough.
If the backend should notify the UI immediately:
SSE is a natural fit.
Now add:
- live chat
- typing indicators
- online presence
- frequent client/server events
Now the communication becomes genuinely bidirectional.
That's where WebSocket starts to make much more sense.
Final rule of thumb
Updates can wait?
→ Polling
Server mainly pushes updates to the client?
→ SSE
Both sides communicate frequently?
→ WebSocket
This is only the starting point.
In production, you may also need to consider authentication, infrastructure, proxies, connection limits, scalability, reconnection behavior, ordering, backpressure, and delivery guarantees.
But from a frontend perspective, the first question is still simple:
How does data actually need to flow?
And most importantly:
Don't choose WebSocket just because the feature is called "real-time."
Choose the simplest communication model that matches the actual data flow of your application.

Top comments (0)