Server-Sent Events

Often when the topic of web application notifications comes up, us web developers are quick to reach for WebSockets. There exists a wealth of information on them in the blogosphere and most importantly: on StackOverflow. However, WebSockets are too big a hammer for many of the cases in which they are most commonly used. Instead, I'd like you introduce you to Server Sent Events.

Why use SSE?

Unlike WebSockets, Server-Sent Events are for one-way communication (server pushes to client). When you're implementing live notifications, this is all you typically need. If you really need full-duplex communication between client and server (maybe you're making a chat app), well, then stick to WebSockets.

I find the main benefit of SSE to be its simplicity - it's just HTTP(S)! When using WebSockets, the connection starts as HTTP, but an “Upgrade” request is sent and the connection is no longer plain HTTP. With SSE, you can continue to write HTTP handlers the way you always have in the language and framework of your choice. You just set some headers, and then write data to the client as you normally would (well, in a simple text format I'll detail below…). This means you don't have to change anything about your web proxy or any other part of your stack. Feel free to continue to use the same authentication you use for all your other HTTP handlers. Sysadmins love it!

Furthermore, the JavaScript interface for working with Server-Sent Events handles automatic reconnects! The reconnect time is even configurable. With all that said, let's move onto the SSE protocol.

The SSE Protocol

The SSE protocol is text-based, and has mercifully few concepts to learn. If all you want to do is send a line of text to the client, this is the format:

data: something happened!\n\n

The second newline means “send the message”. The first newline is there in case you want to send messages that span more than one line. This is useful for sending JSON:

data: {\n
data: "foo": "bar"\n
data: }\n\n

Now, it may be the case that you want to have different kinds of messages in the same “pipe”. Maybe a table is being updated by others and rows can be added or removed. You want to notify the user of both kinds of changes. Well, SSE has you covered there too with the “event” tag:

event: add\n
data: added one\n\n

event: delete\n
data: deleted one\n\n

Finally, you can give events ids with the “id” tag:

id: 1\n
data: i'm one!\n\n

It's past time to give the people what they want: code samples! All server-side code in this post will be in Go, but it should translate to the language of your choice easily.

The Backend

The implementation of a full SSE server is out of the scope of this blog post (which is already too long), if you'd like something more in-depth for Go, I heartily recommend this post from ThoughtBot.

Instead, we'll cover the basics of writing an HTTP handler to do Server-Sent Events in Go. As is customary for lazy bloggers like myself, I've elided error handling. Furthermore, the code assumes you have a some sort of broker which hands out channels (little typesafe queues that Go provides) for clients to read from. This broker also allows ID generation and registering/deregistering clients. Don't get bogged down here, just think “this broker returns things that we can read messages from”. More information on doing this is in the ThoughtBot blog post referenced earlier. I haven't included that here because it will be heavily dependent on your use case.

package main

func main() {
    // set up the fictional broker
    broker := &Broker{}
    http.HandleFunc(eventHandler(broker))

    http.ListenAndServe(":3131", nil)
}

func eventHandler(broker *Broker) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        id := broker.GenerateID()
        msgChan := broker.RegisterNewClient(id)

        w.Header().Set("Content-Type", "text/event-stream")
        w.Header().Set("Cache-Control", "no-cache")
        w.Header().Set("Connection", "keep-alive")

        // in real code, check to make sure this cast works!
        flusher, _ := w.(http.Flusher)

        for {
            select {
            case msg := <-msgChan:
                fmt.Fprintf(w, "data: %s\n\n", msg)
                flusher.Flush()
            case <-r.Context().Done():
                broker.DeregisterClient(id)
                return
            }
        }
    }
}

Ok, there are some things to unpack here. I don't want to talk too much about the Go-specific parts, because this is an SSE tutorial. Here are the important bits:

I claim that this is conceptually straightfoward when compared to WebSockets and allows nearly endless tailoring to fit the needs of your application. To summarize: set the right headers, and write data out in the SSE format until the user leaves or otherwise says they don't want updates anymore.

The Frontend

The EventSource interface is all you need!

var client = new EventSource("https://mysite.local:3131")
client.onmessage = (e) => { console.log(e.data) }

onerror and onopen handlers are also available for handling errors and performing tasks when the connection is opened.

You might do any number of things in the onmessage handler, perhaps rendering a new row in a table, or adding the line of data you just received to an array and having Vue/React/etc rerender your component!

If you broke up your events by type like I mentioned earlier, you can set up your handlers like this:

client.addEventListener('add', (e) => { console.log(e.data) }, false)
client.addEventListener('delete', (e) => { console.log(e.data) }, false)

Thanks!

I hope you've enjoyed this post. Hit me up on Mastodon, especially if I did something wrong!