WebSockets vs. SSE should be about ordering and correctness

A couple days ago, Andros, the maintainer of Django LiveView, wrote an article called HTML over WebSockets: real-time SPAs with barely any JavaScript. While the article does an overall good job of outlining the pros and cons of WebSockets and the LiveView architecture (reduced payloads, simplified stack, etc.), the top comment in the Hacker News discussion for said article says:

For most apps just use SSE and the built-in code for making HTTP requests (Fetch) instead of hacking up your own client side JS to make requests over a WebSocket. The latency is the same because modern browsers multiplex HTTP requests over a single TCP connection that is left open.

To begin with, while Fetch will indeed reuse existing connections, requests are still stateless and therefore every request has to do the same work of decrypting session information, fetching the user from database or cache, and so on. With WebSockets, the connection is authenticated once and the user data is in memory, avoiding all this extra work. Furthermore, in the case of Phoenix LiveView, it actually sends diffs over the wire, rather than HTML, which reduces payload sizes drastically.

However, I’d say those benefits are secondary. The discussion around delivering HTML over WebSockets vs. Server-Sent Events (SSE) with Fetch should really be about event ordering and correctness. When using SSE+Fetch, it is very easy to receive and render events in the wrong order, resulting in confusing user experiences or misleading users into making the wrong decisions. As we will see, attempts to fix this typically mean more client complexity, increased latency, or both!

The ordering issue

Imagine you have an article with three tags: “erlang”, “clojure”, and “javascript”. Then, at the same time, you decide to add the “elixir” tag, while another user decides to remove the “javascript” tag. Here is what most people expect to happen:

Loading event sequence…

In the example above, the database ends up with a list of “erlang”, “clojure”, and “elixir”. And your UI shows the exact same list, with “erlang”, “clojure”, and “elixir”. All is good!

However, thanks to the network, garbage-collectors, proxies, and other factors, the following is also a possible ordering of those events:

Loading event sequence…

In this new version, the delete operation executed first, but its update with the list of tags arrived later. This means your interface will briefly flash a list with “erlang”, “clojure”, and “elixir” as tags, but ultimately display only “erlang” and “clojure”. Basically, your interface will act as if the “elixir” tag was not added at all. And it won’t fix itself unless you refresh or a new event is sent over the wire.

At this point, some people like to yell, “that’s fine, it is eventually consistent.” Except that’s not what eventually consistent means. Generally speaking, an eventually consistent system guarantees that, if no new updates happen, all copies of the data will eventually converge to the same value. That won’t happen here. The interface can remain stale indefinitely, until you refresh the page or another event happens to bring it back in sync.

For comparison, here is how a WebSockets solution would work:

Loading event sequence…

In the example above, the WebSocket connection is responsible for processing user events and recomputing updates, ensuring the correct tags list is delivered regardless of the order of events.

It is not really SSE’s fault

At this point, it is worth noting that Server-Sent Events are not the root cause here. The issue is that you have two streams of data, SSE and Fetch, which can deliver data to the client with no causal ordering between them.

One alternative is to make it so Fetch only performs the update but doesn’t deliver or render the tags list. Instead, the client waits until the update is delivered via SSE. The trouble with this approach is increased latency, as you need to rely on a queue system to deliver your own updates between servers:

Loading latency sequence…

The above is actually similar to how the long polling transport works in Phoenix. Except that we can skip one hop in Phoenix, as the nodes can communicate directly with each other via Distributed Erlang:

Loading latency sequence…

However, WebSockets can skip all of these hops altogether:

Loading latency sequence…

Because WebSockets are bidirectional, they provide a cheap way to preserve ordering between client operations and their responses, without the need to route your own operations through a separate delivery channel.

Of course, WebSockets are not the only solution to this problem. You could, for example, receive the events out of order and then reorder them on the client. This unfortunately sounds much simpler than it actually is. For example, you may have no guarantee that an event creating a resource arrives before its deletion, or that the order of updates matches the database operations. That’s why there are whole platforms, like Electric, designed to solve those issues for you.

Another alternative is to use SSE to simply tell the client, “hey, refresh, you have new data.” Then you don’t need additional hops on the server for your own writes. However, it will most likely lead to increased server load, as you must do additional requests to fetch the latest data instead of them being delivered over SSE. Also, you must be careful not to perform multiple fetches concurrently, instead queue operations on the client and give higher priority to user interactions.

And if it happens that you only need to receive updates from the server, never perform any writes, then plain SSE will do just fine.

Summing up

Next time you are debating SSE + Fetch versus WebSockets, ask yourself: how will I guarantee events are delivered in the right order, and avoid confusing users with stale or incorrect data?

There are several ways to solve this problem. WebSockets give you a bidirectional channel that can act as a simple foundation for preserving causal ordering between user actions and server updates. SSE + Fetch can be made to work, but it often comes with additional latency or machinery that is glossed over in these discussions. And regardless of whether you pick WebSockets or SSE, Phoenix gives you the best bang for your buck, as you can simplify your operations and reduce latency thanks to Elixir and Distributed Erlang.

This article was fully written by a human and proof-read by LLMs. The interactive examples were fully produced by a coding agent under adult supervision.