DEV Community

Cover image for WebSocket vs SSE vs Polling: A Frontend Developer’s Guide to Real-Time Updates
Mohamad Reza Rajabi
Mohamad Reza Rajabi

Posted on

WebSocket vs SSE vs Polling: A Frontend Developer’s Guide to Real-Time Updates

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.
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

WebSocket

Both sides can send messages whenever they want.

Client ⇄ Server
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

which returns:

{
  "id": "42",
  "status": "processing",
  "progress": 60
}
Enter fullscreen mode Exit fullscreen mode

A few seconds later:

{
  "id": "42",
  "status": "completed",
  "progress": 100
}
Enter fullscreen mode Exit fullscreen mode

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]);
Enter fullscreen mode Exit fullscreen mode

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;
  },
});
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

with:

Content-Type: text/event-stream
Enter fullscreen mode Exit fullscreen mode

and send events like:

event: order.updated
data: {"id":"42","status":"processing"}

event: order.updated
data: {"id":"42","status":"shipped"}
Enter fullscreen mode Exit fullscreen mode

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]);
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 ...
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Both sides need to communicate frequently.

This is where WebSocket fits naturally.

The backend might expose:

wss://api.example.com/realtime
Enter fullscreen mode Exit fullscreen mode

and send messages such as:

{
  "type": "message.created",
  "payload": {
    "id": "123",
    "text": "Hello",
    "userId": "42"
  }
}
Enter fullscreen mode Exit fullscreen mode

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;
  };
}, []);
Enter fullscreen mode Exit fullscreen mode

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,
      },
    })
  );
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 ...
Enter fullscreen mode Exit fullscreen mode

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",
});
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)