Server-Sent Events in Go
November 7, 2024
Server-sent events (SSE) are a simple way to support real-time unidirectional communication from a server to a client over an HTTP connection. If you want bidirectional communication, then you should probably use something else, like websockets. SSE uses a single persistent connection and will automatically attempt to reconnect if the connection closes for most clients including browsers. SSE can be a good alternative to long polling. Let’s take a look at how we can implement a simple endpoint using just Go’s standard library.
Go Server
First, let’s set up the small amount of boilerplate we’ll need to run our Go server.
func sseHandler(w http.ResponseWriter, r *http.Request) {
// ...
}
func main() {
http.HandleFunc("/sse", sseHandler)
log.Println("Server starting on :8080...")
http.ListenAndServe(":8080", nil)
}
We’re setting up our server so that we can use the SSE endpoint at localhost:8080/sse
. Now we can start working on the sseHandler
function that we still need to implement. The first thing we’ll want to do is set up some of the headers that are necessary for SSE.
func sseHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
rc := http.NewResponseController(w)
rc.Flush()
// ...
}
In these headers we’re describing that we’ll be sending an event-stream, we don’t want the event-stream to be cached, and that we want the connection to stay active after messages. In this example, we’ll go ahead and flush to send the headers to the client. This is generally best practice, especially in applications where we’re not sure if we’ll be sending the first event any time soon.
In this example, we’ll simply send the current time to the client once per second. You can imagine that instead of the time we could be sending other data such as stock updates, sports scores, etc.
func sseHandler(w http.ResponseWriter, r *http.Request) {
// ...
ticker := time.NewTicker(1 * time.Second)
defer ticker.Stop()
for {
select {
case <-r.Context().Done():
return
case t := <-ticker.C:
fmt.Fprintf(w, "data: %s\n\n", t.Format(time.RFC3339))
rc.Flush()
}
}
}
Let’s break down what we’re doing above. In the first couple lines we’re setting up our ticker which will have a channel that we can read from. Next, we’re setting up our event loop which will wait for one of the channels specified in the select
to have a value we can receive. In the first case, if the channel returned by r.Context().Done()
has a value then we know that the connection has been closed for whatever reason. In the next case, we’ve received an event from our ticker’s channel. In here we can see the format we can use to send a basic message to the client. In this case our field name is ‘data’ and we’re just sending the formatted time. We terminate our message with two newlines \n\n
to let the client know that it’s reached the end of a message.
Field Types
In the previous example, we only showed the data
field, but there are a couple others as well. Additionally, we can send multiple fields per message as well. The fields themselves will be separated by a single newline \n
. Let’s take a look at all of our options for field type.
Event Field
The event field allows us to provide a string declaring the name of an event. This can be useful on the client side if you want to trigger behavior only when you receive a certain type of event. Additionally, it can help clarify the type of data received.
fmt.Fprint(w, "event: timertriggered\n")
fmt.Fprintf(w, "data: %s\n\n", t.Format(time.RFC3339))
Data Field
You can also specify multiple data fields in a single message.
fmt.Fprintf(w, "data: %s\n", t.Format(time.RFC3339))
fmt.Fprint(w, "data: more data here\n\n")
ID Field
The ID field is a convenient way to assign an ID to a message. This can be useful if the ordering of messages is important. In some situations you may want to have the client send the last ID received so that you can catch them back up to speed with any messages they may have missed.
fmt.Fprintf(w, "id: %d\n", id)
fmt.Fprintf(w, "data: %s\n\n", t.Format(time.RFC3339))
Retry Field
The retry field is a way that you can indicate to a client how long you want it to wait (in milliseconds) before it attempts to reconnect to the server if the connection is lost. You wouldn’t typically send this with every single message.
fmt.Fprint(w, "retry: 30000\n") // 30 seconds
fmt.Fprintf(w, "data: %s\n\n", t.Format(time.RFC3339))
You can also send comments in messages. Comments won’t typically be processed by clients, but can be useful to keep a connection alive.
fmt.Fprint(w, ": This is a comment\n\n")
Client
Although the most common client for SSE is the browser, we can quickly test our server using the curl
command.
$ curl -N http://localhost:8080/sse
data: 2024-11-07T16:05:44-05:00
data: 2024-11-07T16:05:45-05:00
data: 2024-11-07T16:05:46-05:00
Conclusion
Server-sent events are a convenient and simple way to implement real-time server-to-client communication. Using Go’s standard library, it’s easy to set up with minimal code. In your actual applications, you should probably have more robust handling for errors and different HTTP methods. If you’re interested in learning more about SSE then you can visit the MDN Web Docs.