Introduction #
In this article, I will demonstrate how to set up a basic SSE (Server Sent Events) endpoint in Go. The complete code related to this article is available in this GitHub repository.
Example: We are going to assume an example of a live crypto price api.
If you are not already familiar with SSE, use the following resources to learn more about it.
- What server sent event is and how to implement it - Ably.
- Using Server Sent Events - MDN Web Docs
- Using Server Sent Events to Simplify Real-time Streaming at Scale - Shopify
Project Setup #
Initialize a new go project.
mkdir go-server-send-events-examples
cd go-server-send-events-examples
go mod init github.com/gurleensethi/go-server-send-events-examples
Create a main.go
file with basic http server setup.
package main import ( "net/http" ) func main() { http.ListenAndServe(":4444", nil) }
main.go
Mocking live data #
Write a function that generates random integers every second. This function will simulate live crypto price data that you will send back to the client.
func main() { ... } // generateCryptoPrice generates price as random integer and sends it the // provided channel every 1 second. func generateCryptoPrice(ctx context.Context, priceCh chan<- int) { r := rand.New(rand.NewSource(time.Now().Unix())) ticker := time.NewTicker(time.Second) outerloop: for { select { case <-ctx.Done(): break outerloop case <-ticker.C: p := r.Intn(100) priceCh <- p } } ticker.Stop() close(priceCh) }
main.go
The generateCryptoPrice
function accepts a one-way write-only channel to which it sends the live price. It uses time.Ticker
with a duration of 1 second to send a price update.
Furthermore, we receive a context
that we can use to stop sending price updates. For instance, if the client disconnects, the context is cancelled, and price updates cease.
math/rand
package is used to generate random integers.
Setting up the SSE (server sent events) endpoint #
SSE endpoint is a GET
endpoint.
package main import ( "bytes" "context" "fmt" "math/rand" "net/http" "strings" "time" ) func main() { http.HandleFunc("/crypto-price", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/event-stream") priceCh := make(chan int) go generateCryptoPrice(r.Context(), priceCh) }) http.ListenAndServe(":4444", nil) } func generateCryptoPrice(ctx context.Context, priceCh chan<- int) { ... }
main.go
You must set the Content-Type
that is sent back to the client as text/event-stream
.
At this point, you will start receiving updates on the priceCh
. Now, you can proceed to send these updates back to the client.
Transforming data #
Before you send the price data back to the client, it must be formatted in a way that SSE (Server Sent Events) understands. You can read more about Event Stream Format here.
The format we are looking for is as follows:
event: price-update
data: {"data":10}
The data is a string, and in our case, we are setting it as a JSON object that has a key named data
which is set to the updated price.
Write a function that, given an event name and data of any type, returns a string formatted according to the Event Stream Format.
package main import ( "bytes" "context" "encoding/json" "fmt" "math/rand" "net/http" "strings" "time" ) func main() { ... } func generateCryptoPrice(ctx context.Context, priceCh chan<- int) { ... } // formatServerSentEvent takes name of an event and any kind of data and transforms // into a server sent event payload structure. // Data is sent as a json object, { "data": <your_data> }. // // Example: // // Input: // event="price-update" // data=10 // Output: // event: price-update\n // data: "{\"data\":10}"\n\n func formatServerSentEvent(event string, data any) (string, error) { m := map[string]any{ "data": data, } buff := bytes.NewBuffer([]byte{}) encoder := json.NewEncoder(buff) err := encoder.Encode(m) if err != nil { return "", err } sb := strings.Builder{} sb.WriteString(fmt.Sprintf("event: %s\n", event)) sb.WriteString(fmt.Sprintf("data: %v\n\n", buff.String())) return sb.String(), nil }
main.go
Sending data back to client #
Now you will use the above function to transform data and send it back to the client.
package main import ( "bytes" "context" "encoding/json" "fmt" "math/rand" "net/http" "strings" "time" ) func main() { http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) w.Write(indexHTML) }) http.HandleFunc("/crypto-price", func(w http.ResponseWriter, r *http.Request) { // šš flusher, ok := w.(http.Flusher) if !ok { http.Error(w, "SSE not supported", http.StatusInternalServerError) return } w.Header().Set("Content-Type", "text/event-stream") priceCh := make(chan int) go generateCryptoPrice(r.Context(), priceCh) // šš for price := range priceCh { event, err := formatServerSentEvent("price-update", price) if err != nil { fmt.Println(err) break } _, err = fmt.Fprint(w, event) if err != nil { fmt.Println(err) break } flusher.Flush() } }) http.ListenAndServe(":4444", nil) } func generateCryptoPrice(ctx context.Context, priceCh chan<- int) { ... } func formatServerSentEvent(event string, data any) (string, error) { ... }
main.go
You cast the http.ResponseWriter
to http.Flusher
to make sure the writer supports SSE.
flusher, ok := w.(http.Flusher)
if !ok {
http.Error(w, "SSE not supported", http.StatusInternalServerError)
return
}
Then you listen to the all the price updates sent to priceCh
channel, transform it and send it back to the client using fmt.Fprint
. It is important to call flusher.Flush()
because it makes sure data is sent to the client.
for price := range priceCh {
event, err := formatServerSentEvent("price-update", price)
if err != nil {
fmt.Println(err)
break
}
_, err = fmt.Fprint(w, event)
if err != nil {
fmt.Println(err)
break
}
flusher.Flush()
}
Testing the endpoint #
You can test the endpoint using curl
.
$ curl -N localhost:4444/crypto-price
event: price-update
data: {"data":39}
event: price-update
data: {"data":41}
event: price-update
data: {"data":93}
Frontend #
If you take a look at the article's github repo, you will find an index.html
which is automatically served along with the http
server.
<html lang="en"> <head> <title>Crypto Price</title> </head> <body> <h1>Crypto Price</h1> <p id="price" style="font-size:40px;">Loading price...</p> <script> const priceEl = document.getElementById("price"); const es = new EventSource("/crypto-price"); es.onerror = (err) => { console.log("onerror", err) }; es.onmessage = (msg) => { console.log("onmessage", msg) }; es.onopen = (...args) => { console.log("onopen", args) }; es.addEventListener("price-update", (event) => { const parsedData = JSON.parse(event.data); const price = parsedData.data; priceEl.innerText = price; }); </script> </body> </html>
index.html
You can see the price getting updated in the browser.