Server Sent Events in Go

Read more articles on Go
github logo
Github Repository
Code for this articles is available on GitHub
Server Sent Events in Go

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.

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.

Golang SSE Browser Update

    TheDeveloperCafe Ā© 2022-2024