Up until last week, the only thing I knew when I needed to send real-time updates to a browser was WebSockets. Until I cam across to this article. Yes, I am a bit afraid to tell you that, since I have more than a decade of experience as a Software Engineer and yet I don’t know anything about SSE. I might be stupid at this point, but, hey, at least I learned something new today.

Even to this day, not a lot of my friends know about SSE, and I’m too afraid to even ask them about it at this point.

I even once worked on a real-time stock trading app, and there was no single person who knew about SSE around the team at the time. Where this is actually a good case for SSE as there’s no need for two-way communication.

But…

What is SSE?

It is basically a standardized way to send real-time updates to a browser. However it sits on top of regular HTTP/HTTPS instead of it’s brother WebSockets who sits on top of WS/WSS (upgraded protocol).

How it work?

SSE Connection

  1. Client open a connection to a server using JavaScript’s EventSource API.
  2. & 3. Server sends a response with specific headers and body:
    • Content-Type: text/event-stream
    • Cache-Control: no-cache
    • Connection: keep-alive

    And for the body, SSE requires a special format:

    data: <data>\n\n
    

    So it should start with data: and end with \n\n. And there is additional fields that can be used as well:

    • event: <event-name>\n Can be used for routing named events in JavaScript with addEventListener.
    • id: <event-id>\n For automatic reconnection tracking.

Best use cases for SSE?

  • LLM response
  • Live feeds like financial ticker
  • Real-time dashboards or telemetry
  • Background jobs & notifications

Demo

Simple demo of SSE with Go and JavaScript.

File structure:

├── index.html
└── main.go

Content of main.go:

package main

import (
	"fmt"
	"net/http"
	"time"
)

func sseHandler(w http.ResponseWriter, r *http.Request) {
	// 1. Set SSE-required headers
	w.Header().Set("Content-Type", "text/event-stream")
	w.Header().Set("Cache-Control", "no-cache")
	w.Header().Set("Connection", "keep-alive")
	w.Header().Set("Access-Control-Allow-Origin", "*") // For local dev

	// 2. Ensure ResponseWriter supports flushing
	flusher, ok := w.(http.Flusher)
	if !ok {
		http.Error(w, "Streaming unsupported!", http.StatusInternalServerError)
		return
	}

	// 3. Stream data periodically until the client disconnects
	count := 1
	for {
		select {
		case <-r.Context().Done():
			// Client closed the connection
			fmt.Println("Client disconnected")
			return
		case <-time.After(1 * time.Second):
			// SSE format requires starting with "data: " and ending with double newlines "\n\n"
			fmt.Fprintf(w, "data: Message #%d at %s\n\n", count, time.Now().Format("15:04:05"))
			flusher.Flush() // Send immediately over the wire
			count++
		}
	}
}

func main() {
	http.HandleFunc("/events", sseHandler)
	fmt.Println("Server running on http://localhost:8080")
	http.ListenAndServe(":8080", nil)
}

Content of index.html:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8" />
  <title>SSE Demo</title>
</head>
<body>
  <h1>Live SSE Stream</h1>
  <ul id="log"></ul>

  <script>
    // Open connection to the Go server's SSE endpoint
    const eventSource = new EventSource('http://localhost:8080/events');

    // Triggered every time the server sends data
    eventSource.onmessage = (event) => {
      const log = document.getElementById('log');
      const item = document.createElement('li');
      item.textContent = event.data;
      log.appendChild(item);
    };

    // Handle network errors or disconnects
    eventSource.onerror = (err) => {
      console.error("EventSource failed:", err);
    };
  </script>
</body>
</html>

Then run the server with go run main.go and open index.html in browswer. And it should look like this:

SSE Demo