germano.dev
Server-Sent Events: the alternative to WebSockets you should be using
Cover image
When developing real-time web applications, WebSockets might be the
first thing that come to your mind. However, Server Sent Events (SSE)
are a simpler alternative that is often superior.
Contents
1. [1]Prologue
2. [2]WebSockets?
3. [3]What is wrong with WebSockets
1. [4]Compression
2. [5]Multiplexing
3. [6]Issues with proxies
4. [7]Cross-Site WebSocket Hijacking
4. [8]Server-Sent Events
5. [9]Let’s write some code
1. [10]The Reverse-Proxy
2. [11]The Frontend
3. [12]The Backend
6. [13]Bonus: Cool SSE features
7. [14]Conclusion
Prologue
Recently I have been curious about the best way to implement
a real-time web application. That is, an application containing one ore
more components which automatically update, in real-time, reacting to
some external event. The most common example of such an application,
would be a messaging service, where we want every message to be
immediately broadcasted to everyone that is connected, without
requiring any user interaction.
After some research I stumbled upon an [15]amazing talk by Martin
Chaov, which compares Server Sent Events, WebSockets and Long Polling.
The talk, which is also [16]available as a blog post, is entertaining
and very informative. I really recommend it. However, it is from 2018
and some small things have changed, so I decided to write this article.
WebSockets?
[17]WebSockets enable the creation of two-way low-latency communication
channels between the browser and a server.
This makes them ideal in certain scenarios, like multiplayer games,
where the communication is two-way, in the sense that both the browser
and server send messages on the channel all the time, and it is
required that these messages be delivered with low latency.
In a First-Person Shooter, the browser could be continuously streaming
the player’s position, while simoultaneously receiving updates on the
location of all the other players from the server. Moreover, we
definitely want these messages to be delivered with as little overhead
as possible, to avoid the game feeling sluggish.
This is the opposite of the traditional [18]request-response
model of [19]HTTP, where the browser is always the one initiating the
communication, and each message has a significant overhead, due to
establishing [20]TCP connections and [21]HTTP headers.
However, many applications do not have requirements this strict. Even
among real-time applications, the data flow is usually asymmetric: the
server sends the majority of the messages while the client mostly just
listens and only once in a while sends some updates. For example, in a
chat application an user may be connected to many rooms each with tens
or hundreds of participants. Thus, the volume of messages received far
exceeds the one of messages sent.
What is wrong with WebSockets
Two-way channels and low latency are extremely good features. Why
bother looking further?
WebSockets have one major drawback: they do not work on top of HTTP, at
least not fully. They require their own TCP connection. They use HTTP
only to establish the connection, but then upgrade it to a standalone
TCP connection on top of which the WebSocket protocol can be used.
This may not seem a big deal, however it means that WebSockets cannot
benefit from any HTTP feature. That is:
* No support for compression
* No support for HTTP/2 multiplexing
* Potential issues with proxies
* No protection from Cross-Site Hijacking
At least, this was the situation when the WebSocket protocol was first
released. Nowadays, there are some complementary standards that try to
improve upon this situation. Let’s take a closer look to the current
situation.
Note: If you do not care about the details, feel free to skip the rest
of this section and jump directly to [22]Server-Sent Events or
the [23]demo.
Compression
On standard connections, [24]HTTP compression is supported by every
browser, and is super easy to enable server-side. Just flip a switch in
your reverse-proxy of choice. With WebSockets the question is more
complex, because there are no requests and responses, but one needs to
compress the individual WebSocket frames.
[25]RFC 7692, released on December 2015, tries to improve the situation
by definining “Compression Extensions for WebSocket”. However, to the
best of my knowledge, no popular reverse-proxy (e.g. nginx, caddy)
implements this, making it impossible to have compression enabled
transparently.
This means that if you want compression, it has to be implemented
directly in your backend. Luckily, I was able to find some libraries
supporting RFC 7692. For example,
the [26]websockets and [27]wsproto Python libraries, and
the [28]ws library for nodejs.
However, the latter suggests not to use the feature:
The extension is disabled by default on the server and enabled by
default on the client. It adds a significant overhead in terms of
performance and memory consumption so we suggest to enable it only
if it is really needed.
Note that Node.js has a variety of issues with high-performance
compression, where increased concurrency, especially on Linux, can
lead to catastrophic memory fragmentation and slow performance.
On the browsers side, [29]Firefox supports WebSocket compression since
version 37.[30]Chrome supports it as well. However, apparently Safari
and Edge do not.
I did not take the time to verify what is the situation on the mobile
landscape.
Multiplexing
[31]HTTP/2 introduced support for multiplexing, meaning that multiple
request/response pairs to the same host no longer require separate TCP
connections. Instead, they all share the same TCP connection, each
operating on its own independent [32]HTTP/2 stream.
This is, again, [33]supported by every browserand is very easy to
transparently enable on most reverse-proxies.
On the contrary, the WebSocket protocol has no support, by default, for
multiplexing. Multiple WebSockets to the same host will each open their
own separate TCP connection. If you want to have two separate WebSocket
endpoints share their underlying connection you must add multiplexing
in your application’s code.
[34]RFC 8441, released on September 2018, tries to fix this limitation
by adding support for “Bootstrapping WebSockets with HTTP/2”. It has
been [35]implemented in Firefox [36]and Chrome. However, as far as I
know, no major reverse-proxy implements it. Unfortunately, I could not
find any implementation in Python or Javascript either.
Issues with proxies
HTTP proxies without explicit support for WebSockets can prevent
unencrypted WebSocket connections to work. This is because the proxy
will not be able to parse the WebSocket frames and close the
connection.
However, WebSocket connections happening over HTTPS should be
unaffected by this problem, since the frames will be encrypted and the
proxy should just forward everything without closing the connection.
To learn more, see [37]“How HTML5 Web Sockets Interact With Proxy
Servers” by Peter Lubbers.
Cross-Site WebSocket Hijacking
WebSocket connections are not protected by the same-origin policy. This
makes them vulnerable to Cross-Site WebSocket Hijacking.
Therefore, WebSocket backends must check the correctness of
the Originheader, if they use any kind of client-cached authentication,
such as [38]cookies or[39]HTTP authentication.
I will not go into the details here, but consider this short example.
Assume a Bitcoin Exchange uses WebSockets to provide its trading
service. When you log in, the Exchange might set a cookie to keep your
session active for a given period of time. Now, all an attacker has to
do to steal your precious Bitcoins is make you visit a site under her
control, and simply open a WebSocket connection to the Exchange. The
malicious connection is going to be automatically authenticated. That
is, unless the Exchange checks the Origin header and blocks the
connections coming from unauthorized domains.
I encourage you to check out the great article about [40]Cross-Site
WebSocket Hijacking by Christian Schneider, to learn more.
Server-Sent Events
Now that we know a bit more about WebSockets, including their
advantages and shortcomings, let us learn about Server-Sent Events and
find out if they are a valid alternative.
[41]Server-Sent Events enable the server to send low-latency push
events to the client, at any time. They use a very simple protocol that
is [42]part of the HTML Standard and [43]supported by every browser.
Unlike WebSockets, Server-sent Events flow only one way: from the
server to the client. This makes them unsuitable for a very specific
set of applications, that is, those that require a communication
channel that is both two-way and low latency, like real-time games.
However, this trade-off is also their major advantage over WebSockets,
because being one-way, Server-Sent Events work seamlessly on top of
HTTP, without requiring a custom protocol. This gives them automatic
access to all of HTTP’s features, such as compression or HTTP/2
multiplexing, making them a very convenient choice for the majority of
real-time applications, where the bulk of the data is sent from the
server, and where a little overhead in requests, due to HTTP headers,
is acceptable.
The protocol is very simple. It uses the text/event-stream Content-Type
and messages of the form:
data: First message
event: join
data: Second message. It has two
data: lines, a custom event type and an id.
id: 5
: comment. Can be used as keep-alive
data: Third message. I do not have more data.
data: Please retry later.
retry: 10
Each event is separated by two empty lines (\n) and consists of various
optional fields.
The data field, which can be repeted to denote multiple lines in the
message, is unsurprisingly used for the content of the event.
The event field allows to specify custom event types, which as we will
show in the next section, can be used to fire different event handlers
on the client.
The other two fields, id and retry, are used to configure the behaviour
of the automatic reconnection mechanism. This is one of the most
interesting features of Server-Sent Events. It ensures that when the
connection is dropped or closed by the server, the client will
automatically try to reconnect, without any user intervention.
The retry field is used to specify the minimum amount of time, in
seconds, to wait before trying to reconnect. It can also be sent by a
server, immediately before closing the client’s connection, to reduce
its load when too many clients are connected.
The id field associates an identifier with the current event. When
reconnecting the client will transmit to the server the last seen id,
using the Last-Event-ID HTTP header. This allows the stream to be
resumed from the correct point.
Finally, the server can stop the automatic reconnection mechanism
altogether by returning an [44]HTTP 204 No Content response.
Let’s write some code!
Let us now put into practice what we learned. In this section we will
implement a simple service both with Server-Sent Events and WebSockets.
This should enable us to compare the two technologies. We will find out
how easy it is to get started with each one, and verify by hand the
features discussed in the previous sections.
We are going to use Python for the backend, Caddy as a reverse-proxy
and of course a couple of lines of JavaScript for the frontend.
To make our example as simple as possible, our backend is just going to
consist of two endpoints, each streaming a unique sequence of random
numbers. They are going to be reachable from /sse1 and /sse2 for
Server-Sent Events, and from /ws1 and /ws2 for WebSockets. While our
frontend is going to consist of a single index.html file, with some
JavaScript which will let us start and stop WebSockets and Server-Sent
Events connections.
[45]The code of this example is available on GitHub.
The Reverse-Proxy
Using a reverse-proxy, such as Caddy or nginx, is very useful, even in
a small example such as this one. It gives us very easy access to many
features that our backend of choice may lack.
More specifically, it allows us to easily serve static files and
automatically compress HTTP responses; to provide support for HTTP/2,
letting us benefit from multiplexing, even if our backend only supports
HTTP/1; and finally to do load balancing.
I chose Caddy because it automatically manages for us HTTPS
certificates, letting us skip a very boring task, especially for a
quick experiment.
The basic configuration, which resides in a Caddyfile at the root of
our project, looks something like this:
localhost
bind 127.0.0.1 ::1
root ./static
file_server browse
encode zstd gzip
This instructs Caddy to listen on the local interface on ports 80 and
443, enabling support for HTTPS and generating a self-signed
certificate. It also enables compression and serving static files from
the static directory.
As the last step we need to ask Caddy to proxy our backend services.
Server-Sent Events is just regular HTTP, so nothing special here:
reverse_proxy /sse1 127.0.1.1:6001
reverse_proxy /sse2 127.0.1.1:6002
To proxy WebSockets our reverse-proxy needs to have explicit support
for it. Luckily, Caddy can handle this without problems, even though
the configuration is slighly more verbose:
@websockets {
header Connection *Upgrade*
header Upgrade websocket
}
handle /ws1 {
reverse_proxy @websockets 127.0.1.1:6001
}
handle /ws2 {
reverse_proxy @websockets 127.0.1.1:6002
}
Finally you should start Caddy with
$ sudo caddy start
The Frontend
Let us start with the frontend, by comparing the JavaScript APIs of
WebSockets and Server-Sent Events.
The [46]WebSocket JavaScript API is very simple to use. First, we need
to create a newWebSocket object passing the URL of the server.
Here wss indicates that the connection is to happen over HTTPS. As
mentioned above it is really recommended to use HTTPS to avoid issues
with proxies.
Then, we should listen to some of the possible events
(i.e. open, message, close, error), by either setting
the on$eventproperty or by using addEventListener().
const ws = new WebSocket("wss://localhost/ws");
ws.onopen = e => console.log("WebSocket open");
ws.addEventListener(
"message", e => console.log(e.data));
The JavaScript API for Server-Sent Events is very similar. It requires
us to create a new EventSource object passing the URL of the server,
and then allows us to subscribe to the events in the same way as
before.
The main difference is that we can also subscribe to custom events.
const es = new EventSource("https://localhost/sse");
es.onopen = e => console.log("EventSource open");
es.addEventListener(
"message", e => console.log(e.data));
// Event listener for custom event
es.addEventListener(
"join", e => console.log(`${e.data} joined`))
We can now use all this freshly aquired knowledge about JS APIs to
build our actual frontend.
To keep things as simple as possible, it is going to consist of only
one index.htmlfile, with a bunch of buttons that will let us start and
stop our WebSockets and EventSources. Like so
We want more than one WebSocket/EventSource so we can test if HTTP/2
multiplexing works and how many connections are open.
Now let us implement the two functions needed by those buttons to work:
const wss = [];
function startWS(i) {
if (wss[i] !== undefined) return;
const ws = wss[i] = new WebSocket("wss://localhost/ws"+i);
ws.onopen = e => console.log("WS open");
ws.onmessage = e => console.log(e.data);
ws.onclose = e => closeWS(i);
}
function closeWS(i) {
if (wss[i] !== undefined) {
console.log("Closing websocket");
websockets[i].close();
delete websockets[i];
}
}
The frontend code for Server-Sent Events is almost identical. The only
difference is the onerror event handler, which is there because in case
of error a message is logged and the browser will attempt to reconnect.
const ess = [];
function startES(i) {
if (ess[i] !== undefined) return;
const es = ess[i] = new EventSource("https://localhost/sse"+i);
es.onopen = e => console.log("ES open");
es.onerror = e => console.log("ES error", e);
es.onmessage = e => console.log(e.data);
}
function closeES(i) {
if (ess[i] !== undefined) {
console.log("Closing EventSource");
ess[i].close()
delete ess[i]
}
}
The Backend
To write our backend, we are going to use [47]Starlette, a simple async
web framework for Python, and [48]Uvicorn as the server. Moreover, to
make things modular, we are going to separate the data-generating
process, from the implementation of the endpoints.
We want each of the two endpoints to generate an unique random sequence
of numbers. To accomplish this we will use the stream id (i.e. 1 or 2)
as part of the [49]random seed.
Ideally, we would also like our streams to be resumable. That is, a
client should be able to resume the stream from the last message it
received, in case the connection is dropped, instead or re-reading the
whole sequence. To make this possible we will assign an ID to each
message/event, and use it to initialize the random seed, together with
the stream id, before each message is generated. In our case, the ID is
just going to be a counter starting from 0.
With all that said, we are ready to write the get_data function which
is responsible to generate our random numbers:
import random
def get_data(stream_id: int, event_id: int) -> int:
rnd = random.Random()
rnd.seed(stream_id * event_id)
return rnd.randrange(1000)
Let’s now write the actual endpoints.
Getting started with Starlette is very simple. We just need to
initialize an app and then register some routes:
from starlette.applications import Starlette
app = Starlette()
To write a WebSocket service both our web server and framework of
choice must have explicit support. Luckily Uvicorn and Starlette are up
to the task, and writing a WebSocket endpoint is as convenient as
writing a normal route.
This all the code that we need:
from websockets.exceptions import WebSocketException
@app.websocket_route("/ws{id:int}")
async def websocket_endpoint(ws):
id = ws.path_params["id"]
try:
await ws.accept()
for i in itertools.count():
data = {"id": i, "msg": get_data(id, i)}
await ws.send_json(data)
await asyncio.sleep(1)
except WebSocketException:
print("client disconnected")
The code above will make sure our websocket_endpoint function is called
every time a browser requests a path starting with /ws and followed by
a number (e.g. /ws1, /ws2).
Then, for every matching request, it will wait for a WebSocket
connection to be established and subsequently start an infinite loop
sending random numbers, encoded as a JSON payload, every second.
For Server-Sent Events the code is very similar, except that no special
framework support is needed. In this case, we register a route matching
URLs starting with /sseand ending with a number (e.g. /sse1, /sse2).
However, this time our endpoint just sets the appropriate headers and
returns a StreamingResponse:
from starlette.responses import StreamingResponse
@app.route("/sse{id:int}")
async def sse_endpoint(req):
return StreamingResponse(
sse_generator(req),
headers={
"Content-type": "text/event-stream",
"Cache-Control": "no-cache",
"Connection": "keep-alive",
},
)
StreamingResponse is an utility class, provided by Starlette, which
takes a generator and streams its output to the client, keeping the
connection open.
The code of sse_generator is shown below, and is almost identical to
the WebSocket endpoint, except that messages are encoded according to
the Server-Sent Events protocol:
async def sse_generator(req):
id = req.path_params["id"]
for i in itertools.count():
data = get_data(id, i)
data = b"id: %d\ndata: %d\n\n" % (i, data)
yield data
await asyncio.sleep(1)
We are done!
Finally, assuming we put all our code in a file named server.py, we can
start our backend endpoints using Uvicorn, like so:
$ uvicorn --host 127.0.1.1 --port 6001 server:app &
$ uvicorn --host 127.0.1.1 --port 6002 server:app &
Bonus: Cool SSE features
Ok, let us now conclude by showing how easy it is to implement all
those nice features we bragged about earlier.
Compression can be enabled by changing just a few lines in our
endpoint:
@@ -32,10 +33,12 @@ async def websocket_endpoint(ws):
async def sse_generator(req):
id = req.path_params["id"]
+ stream = zlib.compressobj()
for i in itertools.count():
data = get_data(id, i)
data = b"id: %d\ndata: %d\n\n" % (i, data)
- yield data
+ yield stream.compress(data)
+ yield stream.flush(zlib.Z_SYNC_FLUSH)
await asyncio.sleep(1)
@@ -47,5 +50,6 @@ async def sse_endpoint(req):
"Content-type": "text/event-stream",
"Cache-Control": "no-cache",
"Connection": "keep-alive",
+ "Content-Encoding": "deflate",
},
)
We can then verify that everything is working as expected by checking
the DevTools:
SSE Compression
Multiplexing is enabled by default since Caddy supports HTTP/2. We can
confirm that the same connection is being used for all our SSE requests
using the DevTools again:
SSE Multiplexing
Automatic reconnection on unexpected connection errors is as simple as
reading the [50]Last-Event-ID header in our backend code:
< for i in itertools.count():
---
> start = int(req.headers.get("last-event-id", 0))
> for i in itertools.count(start):
Nothing has to be changed in the front-end code.
We can test that it is working by starting the connection to one of the
SSE endpoints and then killing uvicorn. The connection will drop, but
the browser will automatically try to reconnect. Thus, if we re-start
the server, we will see the stream resume from where it left off!
Notice how the stream resumes from the message 243. Feels like magic 🔥
Prova
Conclusion
WebSockets are a big machinery built on top of HTTP and TCP to provide
a set of extremely specific features, that is two-wayand low
latency communication.
In order to do that they introduce a number of complications, which end
up making both client and server implementations more complicated than
solutions based entirely on HTTP.
These complications and limitations have been addressed by new specs
([51]RFC 7692, [52]RFC 8441), and will slowly end up implemented in
client and server libraries.
However, even in a world where WebSockets have no technical downsides,
they will still be a fairly complex technology, involving a large
amount of additional code both on clients and servers. Therefore, you
should carefully consider if the addeded complexity is worth it, or if
you can solve your problem with a much simpler solution, such as
Server-Sent Events.
__________________________________________________________________
That’s all, folks! I hope you found this post interesting and maybe
learned something new.
[53]Feel free to check out the code of the demo on GitHub, if you want
to experiment a bit with Server Sent Events and Websockets.
[54]I also encourage you to read the spec, because it surprisingly
clear and contains many examples.
References
Visible links
1. https://germano.dev/sse-websockets/#prologue
2. https://germano.dev/sse-websockets/#websockets
3. https://germano.dev/sse-websockets/#what-is-wrong-with-websockets
4. https://germano.dev/sse-websockets/#compression
5. https://germano.dev/sse-websockets/#multiplexing
6. https://germano.dev/sse-websockets/#proxies
7. https://germano.dev/sse-websockets/#hijacking
8. https://germano.dev/sse-websockets/#sse
9. https://germano.dev/sse-websockets/#code
10. https://germano.dev/sse-websockets/#reverse-proxy
11. https://germano.dev/sse-websockets/#frontend
12. https://germano.dev/sse-websockets/#backend
13. https://germano.dev/sse-websockets/#bonus
14. https://germano.dev/sse-websockets/#conclusion
15. https://www.youtube.com/watch?v=n9mRjkQg3VE
16. https://www.smashingmagazine.com/2018/02/sse-websockets-data-flow-http2/#comments-sse-websockets-data-flow-http2
17. https://tools.ietf.org/html/rfc6455
18. https://en.wikipedia.org/wiki/Request–response
19. https://developer.mozilla.org/en-US/docs/Web/HTTP
20. https://en.wikipedia.org/wiki/Transmission_Control_Protocol
21. https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers
22. https://germano.dev/sse-websockets/#sse
23. https://germano.dev/sse-websockets/#code
24. https://en.wikipedia.org/wiki/HTTP_compression
25. https://tools.ietf.org/html/rfc7692
26. https://websockets.readthedocs.io/en/stable/extensions.html
27. https://github.com/python-hyper/wsproto/
28. https://github.com/websockets/ws
29. https://developer.mozilla.org/en-US/docs/Mozilla/Firefox/Releases/37#networking
30. https://chromestatus.com/feature/6555138000945152
31. https://tools.ietf.org/html/rfc7540
32. https://tools.ietf.org/html/rfc7540#section-5
33. https://caniuse.com/http2
34. https://tools.ietf.org/html/rfc8441
35. https://bugzilla.mozilla.org/show_bug.cgi?id=1434137
36. https://chromestatus.com/feature/6251293127475200
37. https://www.infoq.com/articles/Web-Sockets-Proxy-Servers/
38. https://developer.mozilla.org/en-US/docs/Web/HTTP/Cookies
39. https://developer.mozilla.org/en-US/docs/Web/HTTP/Authentication
40. https://christian-schneider.net/CrossSiteWebSocketHijacking.html#main
41. https://html.spec.whatwg.org/#server-sent-events
42. https://html.spec.whatwg.org/#server-sent-events
43. https://caniuse.com/eventsource
44. https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/204
45. https://github.com/tyrion/sse-websockets-demo
46. https://developer.mozilla.org/en-US/docs/Web/API/Websockets_API
47. https://www.starlette.io/
48. https://www.uvicorn.org/
49. https://en.wikipedia.org/wiki/Random_seed
50. https://html.spec.whatwg.org/multipage/server-sent-events.html#last-event-id
51. https://tools.ietf.org/html/rfc7692
52. https://tools.ietf.org/html/rfc8441
53. https://github.com/tyrion/sse-websockets-demo
54. https://html.spec.whatwg.org/#server-sent-events
Hidden links:
56. https://germano.dev/sse-websockets/#contents
57. https://germano.dev/sse-websockets/#prologue
58. https://germano.dev/sse-websockets/#websockets
59. https://germano.dev/sse-websockets/#what-is-wrong-with-websockets
60. https://germano.dev/sse-websockets/#compression
61. https://germano.dev/sse-websockets/#multiplexing
62. https://germano.dev/sse-websockets/#proxies
63. https://germano.dev/sse-websockets/#hijacking
64. https://germano.dev/sse-websockets/#sse
65. https://germano.dev/sse-websockets/#code
66. https://germano.dev/sse-websockets/#reverse-proxy
67. https://germano.dev/sse-websockets/#frontend
68. https://germano.dev/sse-websockets/#backend
69. https://germano.dev/sse-websockets/#bonus
70. https://germano.dev/sse-websockets/#conclusion