DEV Community

Cover image for SSE, WebSockets, or Polling? Build a Real-Time Stock App with React and Hono
Itay Ben Ami
Itay Ben Ami

Posted on

SSE, WebSockets, or Polling? Build a Real-Time Stock App with React and Hono

Have you ever tried building a dashboard that updates in real time? Or maybe wondered how sites like ChatGPT stream response data to the page? Or how Figma makes collaborative editing seem so seamless?

Today we'll try to answer these questions by looking at some methods to stream real-time data and build our own small real-time stock market feed app in Hono and React to illustrate the differences between these methods. For the visual side of things we'll be using shadcn charts to display randomly generated data.

Short Polling

The first method we'll take a look at is short polling, which is a technique for somewhat real-time communication.
It involves the client querying the server at regular intervals, usually a few seconds between requests, then updating the data on the client if necessary.

While this isn't exactly "real time", it can be good enough for many scenarios where updates don't need to be immediate or are infrequent, like checking for updates in an email inbox or a weather app where updates can occur every few minutes.

Short polling offers a few advantages:

  • It's pretty simple to implement
  • It doesn't require a persistent connection
  • It works with any HTTP client or browser

So let's take a look at the code:

Server

const updateStockPrices = () => {
  stocks = stocks.map((stock) => ({
    ...stock,
    // Randomly increment or decrement the stock price
    price: Math.abs(stock.price + getStockPriceChange()),
  }));
};

setInterval(updateStockPrices, 5 * 1000);

app.get("/stocks", (c) => {
  return c.json({ stocks });
});
Enter fullscreen mode Exit fullscreen mode

Client

const { data: stocksData } = useQuery({
  queryKey: ["stocks"],
  queryFn: async () =>
    fetch("http://localhost:3000/stocks").then((response) =>
      response.json() as Promise<{ stocks: Stock[] }>
    ),
  refetchInterval: 5 * 1000,
});
Enter fullscreen mode Exit fullscreen mode

For the server, I simulated frequent data updates by updating stock prices randomly. For simplicity's sake I set an interval to update the data every 5 seconds (even though data updates in real-time apps can be much more unpredictable). As for the client, I used TanStack Query's refetchInterval to fetch the data from the server every 5 seconds, which is generally a pretty solid refetch interval.

And now we can see our data updating in our page, and looking quite nice!

While simple, this solution has a few issues:

  • First of all, in this type of app the data can change quite frequently. This means that the interval might be too low and the client might "miss" some data on the server. While we can always lower the fetching interval, this can come at the cost of load to our server and additional network traffic on the client.
  • Many times, we might be querying the server when the data hasn't really changed, creating even more unnecessary load on the server.
  • While fetching the data often lowers the margin of error we might encounter on the client, we still don't get a true real time experience.

WebSockets

The next method we'll look at is WebSockets. Using WebSockets allows a persistent, bi-directional (two-way) communication channel between a client and a server. Unlike traditional HTTP, which follows a request-response model, WebSockets keep the connection open, so both the client and server can send messages to each other at any time.

WebSocket connections can be created over HTTP (ws) or HTTPS (wss), with wss ensuring the communication is encrypted and secure.

How it works

  1. Handshake: The connection starts with an HTTP handshake, after which the protocol "upgrades" from HTTP to WebSocket.
  2. Persistent Connection: Once established, the connection stays open, eliminating the need for repeated requests, and both client and server can send messages.

WebSockets can be great for building chat apps, real-time collaborative editing (as demonstrated by Figma's multiplayer technology) or live updating dashboards (like the one we're trying to build!).

WebSockets also offer a few advantages:

  • They offer true real-time communication, unlike short polling, making them ideal for high-frequency low latency updates.
  • Network efficiency: In that the connection between client and server remains open, reducing repeated handshakes and network overhead.
  • Bi-directional updates: Both client and server can message each other.

Now let's implement them in our app:

Server

const updateStockPrices = () => {
  stocks = stocks.map((stock) => ({
    ...stock,
    price: stock.price + getStockPriceChange(),
    time: Date.now(),
  }));

  eventEmitter.emit("stocksUpdated");
  scheduleNextUpdate();
};

app.get(
  "/stocks-ws",
  upgradeWebSocket((c) => {
    let sendEventsToClient = () => {};

    return {
      onOpen(_, ws) {
        sendEventsToClient = () => {
          ws.send(JSON.stringify({ stocks }));
        };

        eventEmitter.on("stocksUpdated", sendEventsToClient);

        ws.send(JSON.stringify({ stocks }));
      },
      onClose: () => {
        eventEmitter.off("stocksUpdated", sendEventsToClient);
      },
    };
  }),
);
Enter fullscreen mode Exit fullscreen mode

Client
// useWebsocket.ts

import { useEffect, useState } from 'react';

const sockets = new Map<string, { ws: WebSocket; refCount: number }>();

export function useWebsocket<T>(url: string, valueParser: (value: string) => T = JSON.parse) {
  const [isReady, setIsReady] = useState(false);
  const [value, setValue] = useState<T | null>(null);

  useEffect(() => {
    if (!sockets.has(url)) {
      const ws = new WebSocket(url);
      sockets.set(url, { ws, refCount: 0 });

      ws.onopen = () => setIsReady(true);
      ws.onmessage = event => setValue(valueParser(event.data));
      ws.onclose = () => {
        sockets.delete(url);
      };
    }

    const socket = sockets.get(url);

    if (!socket) return;

    socket.refCount += 1;

    return () => {
      socket.refCount -= 1;

      // Close the WebSocket if no more references exist
      if (socket.refCount === 0 && socket.ws.OPEN) {
        socket.ws.close();
        sockets.delete(url);
      }
    };
  }, [url]);

  const send = (data: string | ArrayBuffer | SharedArrayBuffer | Blob | ArrayBufferView) => {
    const socket = sockets.get(url);
    if (socket && socket.ws.readyState === WebSocket.OPEN) {
      socket.ws.send(data);
    }
  };

  return { isReady, value, send };
}

Enter fullscreen mode Exit fullscreen mode

// App.tsx

const { isReady, value: stocksData } = useWebsocket<{ stocks: Stock[] }>('ws://localhost:3000/stocks-ws');

if (!isReady) return;

// render logic
Enter fullscreen mode Exit fullscreen mode

For the server I used @hono/node-ws and NodeJS's built in EventEmitter to update my endpoint when the data changed. For the client I created my own custom hook, which used the Singleton design pattern to manage open WebSocket connections to different urls and avoid duplication. I also added an optional valueParser function that will parse the value from a string to an object.

Now we can see the data appearing in real time in our UI, and the graphs being more accurate as a result!

It's also worth noting that WebSockets can also integrate into your app's existing TanStack Query setup by sending messages of updates and not data, while TanStack Query handles cache and data revalidation, as can be seen in TkDodo's blogpost.
For example, in our scenario you could send a messageType: 'stockUpdate' to the client, and let TanStack Query handle data revalidation based on that event.

So what are some of the drawbacks of WebSockets?

  • Complexity: WebSockets require you to handle connection lifecycle events, such as errors and reconnections. While the code example I provided could suffice for simple use cases, more complex use cases might arise, like automatic reconnection and queueing messages sent by the client when the connection wasn't open. For that, you can either extend this code or use an external library like react-use-websocket for a relatively lightweight react implementation or socketio for both client and server SDKs.

  • HTTP/1.1 limitations: WebSockets are primarily designed to work with HTTP/1.1.
    In HTTP/1.1, every request to the server means instantiating a new TCP connection. This can be problematic, because most web browsers limit the number of concurrent connection per origin to about 6.
    This issue is much less relevant in HTTP/2, which supports multiplexing, allowing multiple streams of data over a single TCP connection.
    While there is an RFC on running WebSockets over HTTP/2, it lacks wide support with many client libraries.

  • Resource consumption: WebSocket connections remain open for their duration, consuming server resources such as memory and CPU. High numbers of concurrent connections can be intensive on server resources.

  • Security risks: WebSockets bypass many standard HTTP security mechanisms like CORS or CSRF protections, which puts them at risk of Cross Site WebSocket Hijacking attacks and makes them harder to inspect for traditional HTTP based security tools.

SSE

Server sent events (SSE for short) is a web technology that allows a server to push real-time updates to a client over a single HTTP connection. Unlike WebSockets, which provide two-way communication, SSE is designed for one-way communication, where the server pushes updates to the client. SSE is built upon the EventSource API.

How it works

  1. The client initiates an HTTP request, with the Accept: text/event-stream header.
  2. The server responds with an HTTP 200 status and headers that define the connection is an SSE stream, like Content-Type: text/event-stream.
  3. The server then sends data over the open connection to the client, which processes the events using the EventSource API.

SSE can be great in scenarios where the server needs to constantly stream real-time data to the client, such as ChatGPT's responses (which use SSE to deliver parts of the response in real time), live notifications (like for updating scores in sports apps), or live-updating dashboards.

So what advantages does SSE have over WebSockets?

  • Simplicity: SSE can be much simpler than WebSockets, as things like client reconnection are handled automatically by the EventSource API, with no need for additional code or libraries.

  • Lightweight: SSE is designed for one-way communication (which might be just what we need in our app!). It doesn't require the overhead of managing bidirectional communication, which in turn can lead to lower resource usage.

  • HTTP/2 compatible: SSE leverages HTTP/2’s multiplexing, allowing multiple streams over a single connection and avoiding the browser’s concurrent connection limits.

  • Security: SSE integrates with HTTP features like CORS, and works more reliably with firewalls, load balancers, and HTTP security tools compared to WebSockets.

With all that said, let's get to coding!

Server

app.get("/stocks-sse", async (c) => {
  return streamSSE(c, async (stream) => {
    const sendEventToClient = async () => {
      await stream.writeSSE({ data: JSON.stringify({ stocks }) });
    };

    eventEmitter.on("stocksUpdated", sendEventToClient);

    stream.writeSSE({ data: JSON.stringify({ stocks }) });

    stream.onAbort(() => {
      eventEmitter.off("message", sendEventToClient);
    });

    while (true) {
      await stream.sleep(200);
    }
  });
});

Enter fullscreen mode Exit fullscreen mode

Client
// useSSE.ts

import { useEffect, useState } from 'react';

const eventSources = new Map<string, { es: EventSource; refCount: number }>();

export function useSSE<T>(url: string, valueParser: (value: string) => T = JSON.parse) {
  const [isConnected, setIsConnected] = useState(false);
  const [value, setValue] = useState<T | null>(null);
  const [error, setError] = useState<Event | null>(null);

  useEffect(() => {
    if (!eventSources.has(url)) {
      const es = new EventSource(url);
      eventSources.set(url, { es, refCount: 0 });

      es.onopen = () => setIsConnected(true);
      es.onmessage = event => setValue(valueParser(event.data));
      es.onerror = err => {
        setError(err);
        setIsConnected(false);

        // Optionally close the connection on certain errors
        if (es.readyState === EventSource.CLOSED) {
          eventSources.delete(url);
        }
      };
    }

    const eventSource = eventSources.get(url);

    if (!eventSource) return;

    eventSource.refCount += 1;

    return () => {
      eventSource.refCount -= 1;

      // Close the EventSource if no more references exist
      if (eventSource.refCount === 0) {
        eventSource.es.close();
        eventSources.delete(url);
      }
    };
  }, [url, valueParser]);

  return { isConnected, value, error };
}

Enter fullscreen mode Exit fullscreen mode

// App.tsx

 const {isConnected, value: stocksData} = useSSE<{stocks: Stock[]}>("http://localhost:3000/stocks-sse");

  if (!isConnected) return;

  // render logic
Enter fullscreen mode Exit fullscreen mode

Our server logic is pretty simple thanks to Hono's built in streaming helper. On the client our implementation is almost identical to our WebSockets one thanks to their very similar APIs. Two key differences are we don't need to worry about reconnection because the EventSource API has us covered and creating a connections Singleton, while still important to reduce network load, isn't as important when working with HTTP/2.

Now we can see the SSE chart in our app updating in real time just like the WebSockets one.

Are there other methods?

While for this example I feel the methods I covered were the most appropriate, there are other real-time options you can implement in your app.

  • If you're looking for streaming between backend microservices, grpc streams offer client streaming, server streaming and bi-directional streaming options.
  • For REST alternatives, both TRPC subscriptions (offer both Websocket and SSE based implementations) and GraphQL subscriptions (mostly built upon WebSockets) offer nice streaming APIs.
  • For real-time communication used mostly for video and voice calls, check out WebRTC.

Conclusion

We've looked at 3 different methods for real-time updates, all of which have their own unique advantages. For our app, I would say that SSE is probably what I would go for thanks to it providing better real-time updates than short polling and being simpler and more lightweight than WebSockets. That being said, different requirements pose different challenges and there isn't a perfect solution. It's always best to assess the functionality we want to provide and try to find the method that would provide the best user experience and be as simple and maintainable as possible.

  • If we want to follow a long process that runs on the server in our client, or maybe have notifications that don't need to pop up immediately, I would recommend short polling for its simplicity and for not requiring diving into additional APIs.

  • In case we do want an immediate response on the client, and only require for the client to receive updates from the server (for things like live updates or real-time notifications), I would recommend SSE.

  • If we want the bi-directional communication and for our client to be able to send messages to the server, for things like chat apps or collaborative editing, I would recommend WebSockets, and perhaps pairing it with one of the libraries mentioned above.

I hope this article taught you a thing or two about real-time data and how to handle it in your app. If you’ve tried other methods or have ideas from your own projects, I’d love to hear about them in the comments!

You can find the complete code for the project built in this article in this GitHub repository and a live demo in this URL. Happy coding!

Top comments (5)

Collapse
 
paripsky profile image
paripsky

I've been wanting to get into Hono for a while and now I have an excuse, great post!

Collapse
 
miketalbot profile image
Mike Talbot ⭐

WebSockets and SSE frequently break down across corporate networks, so using a library with a fall back is a good idea.

Collapse
 
bendavid22 profile image
Ben

Thanks for sharing the demo and GitHub repo!

Collapse
 
adi_fermon_0244b1ff4b1876 profile image
Adi Fermon

Wonderful post! I learned a lot from it

Collapse
 
shakedbl12 profile image
Shaked Blushtein

Great post! I really loved the breakdown 🙌🏼