Now, let’s talk about the quiet, unassuming hero of the real-time web: Server-Sent Events, or SSE. While everyone’s busy setting up the plumbing for a full-duplex WebSocket connection, SSE is over there in the corner, effortlessly pushing data to your client with about 90% less drama. It’s the technology you use when you don’t need a full conversation, just a very informative monologue from your server. Think stock tickers, live notifications, or progress updates—that’s SSE’s sweet spot.

The beauty of SSE is its breathtaking simplicity. It runs on plain old HTTP. No fancy protocols, no complex handshakes. This means it works everywhere, often even sneaking through corporate firewalls that get twitchy about WebSockets. The client is a native browser API called EventSource, and the server just has to uphold one simple promise: keep an HTTP connection open and send a stream of text. It’s so stupidly simple it feels like you’re getting away with something.

The Protocol: It’s Just Text Over HTTP

Let’s demystify the protocol. The server’s response should have the header Content-Type: text/event-stream, and it must not close the connection. The data you send is formatted in a specific, minimalist way. Each “event” is a block of key-value pairs separated by newlines. The most important fields are data and event.

data: This is a line of text data.\n
data: This is a second line, which together with the first\n
data: forms a single message. Notice the newlines.\n
\n

That block above sends one message. The double newline (\n\n) is the crucial part—it’s the delimiter that tells the client the event is complete. You can also specify a custom event type and even an ID for automatic reconnection.

event: statusUpdate\n
id: 12345\n
data: {"message": "Task completed 50%", "value": 50}\n
\n

Building a Robust SSE Server in Go

In Go, this means we need an HTTP handler that sets the right headers, hijacks the connection to prevent the standard library from closing it, and then enters a loop to send messages. Here’s the skeleton of a production-ready handler.

package main

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

func sseHandler(w http.ResponseWriter, r *http.Request) {
    // 1. Set the necessary 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", "*") // Adjust for production!

    // 2. Optional: Flush the headers to send them to the client immediately
    flusher, ok := w.(http.Flusher)
    if !ok {
        http.Error(w, "Streaming unsupported!", http.StatusInternalServerError)
        return
    }

    // 3. Hijack the connection? Not strictly necessary, but gives more control.
    // Instead, we'll use the Flusher and write directly in a loop.

    // Notify the client that the connection is open
    fmt.Fprintf(w, "event: connected\n")
    fmt.Fprintf(w, "data: Connection established at %v\n", time.Now())
    fmt.Fprintf(w, "\n")
    flusher.Flush()

    // 4. Create a ticker or channel to simulate events
    ticker := time.NewTicker(2 * time.Second)
    defer ticker.Stop()

    // 5. Listen for client disconnect
    notify := r.Context().Done()

    for {
        select {
        case <-notify:
            log.Println("Client disconnected")
            return
        case t := <-ticker.C:
            // Send a message
            fmt.Fprintf(w, "event: message\n")        // Optional event name
            fmt.Fprintf(w, "data: %v\n", t.String()) // The payload
            fmt.Fprintf(w, "\n")                     // The crucial double newline
            flusher.Flush()                          // Send it immediately
        }
    }
}

func main() {
    http.HandleFunc("/events", sseHandler)
    log.Fatal(http.ListenAndServe(":8080", nil))
}

The Client Side: Dead Simple JavaScript

Meanwhile, on the client, your code is a breath of fresh air.

<script>
const eventSource = new EventSource('/events');

// Listen for generic messages (no 'event' field specified)
eventSource.onmessage = function(event) {
    console.log('Generic message:', event.data);
};

// Listen for our custom named event
eventSource.addEventListener('statusUpdate', function(event) {
    const data = JSON.parse(event.data); // Our sent data was a JSON string
    console.log('Status Update:', data.message);
});

// Handle errors - which often includes the connection being closed
eventSource.onerror = function(err) {
    console.error("EventSource failed:", err);
};
</script>

The Rough Edges and Pitfalls

It’s not all roses. The biggest pitfall is connection limits. Browsers famously limit the number of simultaneous connections to a single domain (often around 6). Since SSE holds one connection open per client, you can easily exhaust this limit if you have multiple SSE streams and other assets all being fetched from the same backend. The solution? Use a dedicated subdomain for your events.

Another gotcha is the lack of support for binary data. SSE is text-only. If you need to push binary blobs, you’re back to WebSockets. You can, of course, encode your binary data to Base64, but then you’re paying a 33% bandwidth penalty.

Finally, while the EventSource API automatically reconnects if dropped (using the last received id field), it’s incredibly basic. It uses a simple retry timer with no backoff strategy. For more robust reconnection logic, you might find yourself needing a small wrapper library.

So, when should you choose SSE over WebSockets? Ask yourself this: does the client need to send a significant amount of data back to the server? If the answer is “not really,” or “just occasional AJAX calls,” then SSE is your winner. It’s simpler, more resilient, and gets you 95% of the way there for half the effort. It’s the pragmatic choice, and I’m always in favor of pragmatism.