SSE vs WebSockets in Go

When to Use Which

Web
Use SSE for simple server-to-client streaming with standard HTTP, and WebSockets for full-duplex, real-time bidirectional communication.

When the server needs to talk first

You are building a dashboard that displays real-time sensor readings. The server collects data every second and pushes it to the browser. You also have a chat feature where users send messages back to the server. You reach for WebSockets because "real-time" screams WebSockets. Then you realize the chat is the only part that needs two-way traffic. The dashboard just needs to listen. You have over-engineered half your app.

Go makes this distinction clear. Server-Sent Events (SSE) handle server-to-client streaming using standard HTTP. WebSockets handle full-duplex communication but require a protocol upgrade and a third-party library. Picking the right tool saves you from managing connection state, handling reconnections, and fighting proxy timeouts.

The difference in plain words

WebSockets create a persistent, bidirectional pipe between client and server. Both sides can send messages at any time. The connection starts as HTTP, performs a handshake, and switches protocols. Once upgraded, the connection is no longer HTTP. It speaks the WebSocket protocol.

Server-Sent Events keep the connection as HTTP. The server sends a stream of text events to the client. The client cannot send data over the stream. If the client needs to send data, it makes a separate HTTP request. SSE relies on standard HTTP headers and the browser's built-in EventSource API.

Think of WebSockets like a walkie-talkie channel. Both sides talk and listen on the same frequency. Think of SSE like a radio broadcast. The station sends audio, and you listen. If you want to call the station, you pick up the phone and dial.

Server-Sent Events: HTTP with a stream

SSE requires no third-party libraries. You use the standard net/http package. The implementation boils down to setting specific headers, writing data in the SSE format, and flushing the response buffer so the client receives data immediately.

Here is the simplest SSE handler: set headers, write an event, flush, and repeat.

// sseHandler sends a simple event stream to the client.
func sseHandler(w http.ResponseWriter, r *http.Request) {
    // Set headers required for SSE: text/event-stream content type and no caching.
    w.Header().Set("Content-Type", "text/event-stream")
    w.Header().Set("Cache-Control", "no-cache")
    // Flusher interface lets us push data immediately without waiting for the buffer to fill.
    flusher, ok := w.(http.Flusher)
    if !ok {
        // If the writer doesn't support flushing, abort with a 500 error.
        http.Error(w, "Streaming unsupported", http.StatusInternalServerError)
        return
    }
    // Write the first event and flush to establish the connection.
    fmt.Fprintf(w, "data: connected\n\n")
    flusher.Flush()
}

The code sets Content-Type to text/event-stream. This tells the browser to treat the response as an event stream. Cache-Control: no-cache prevents proxies and browsers from caching the stream. The type assertion w.(http.Flusher) checks if the response writer supports flushing. If it does, ok is true and you get a flusher interface. If not, the handler returns an error. The fmt.Fprintf call writes the event data. The double newline \n\n is the SSE protocol delimiter that signals the end of an event. flusher.Flush() forces the buffer to the network.

SSE is just HTTP with a twist. Flush or you get nothing.

Real-world SSE: handling disconnects

A real stream must stop when the client disconnects. Go's http.Request carries a context.Context that tracks the connection lifecycle. When the client closes the tab or the network drops, the context cancels. You use select to listen for context cancellation alongside your data source.

Here is a handler that streams timestamps until the client disconnects.

// streamData sends periodic updates until the client disconnects or context cancels.
func streamData(w http.ResponseWriter, r *http.Request) {
    w.Header().Set("Content-Type", "text/event-stream")
    w.Header().Set("Cache-Control", "no-cache")
    flusher, _ := w.(http.Flusher)
    // Context tracks client disconnects; Done channel closes when the connection drops.
    ctx := r.Context()
    ticker := time.NewTicker(1 * time.Second)
    defer ticker.Stop()
    for {
        select {
        // Exit the loop if the client closes the connection or the server shuts down.
        case <-ctx.Done():
            return
        // Send a timestamp on every tick.
        case <-ticker.C:
            fmt.Fprintf(w, "data: %s\n\n", time.Now().Format(time.RFC3339))
            flusher.Flush()
        }
    }
}

The handler extracts ctx from the request. The ticker generates events every second. The select statement waits for either the ticker or the context. If ctx.Done() fires, the handler returns, closing the response and cleaning up resources. The defer ticker.Stop() ensures the ticker is cleaned up even if the function returns early.

Context is plumbing. Run it through every long-lived call site.

WebSockets: the full-duplex pipe

WebSockets require a protocol upgrade. Go's standard library does not include a WebSocket server. You use a third-party library like gorilla/websocket or nhooyr.io/websocket. The library handles the HTTP handshake and upgrades the connection to the WebSocket protocol.

Here is a minimal WebSocket handler using gorilla/websocket.

import "github.com/gorilla/websocket"

// upgrader configures the WebSocket handshake.
var upgrader = websocket.Upgrader{
    // CheckOrigin allows all origins for simplicity; restrict this in production.
    CheckOrigin: func(r *http.Request) bool { return true },
}

// wsHandler upgrades the HTTP request to a WebSocket connection and echoes messages.
func wsHandler(w http.ResponseWriter, r *http.Request) {
    conn, err := upgrader.Upgrade(w, r, nil)
    if err != nil {
        // Log the error and return; the client receives an HTTP error response.
        http.Error(w, err.Error(), http.StatusInternalServerError)
        return
    }
    defer conn.Close()
    for {
        // ReadMessage blocks until a message arrives or the connection closes.
        _, message, err := conn.ReadMessage()
        if err != nil {
            break
        }
        // Echo the message back to the client.
        err = conn.WriteMessage(websocket.TextMessage, message)
        if err != nil {
            break
        }
    }
}

The upgrader handles the handshake. CheckOrigin is a security check; in production, you validate the origin to prevent cross-site WebSocket hijacking. upgrader.Upgrade performs the handshake and returns a *websocket.Conn. If the upgrade fails, you return an HTTP error. The defer conn.Close() ensures the connection closes when the handler returns. The loop reads messages, checks for errors, and writes them back.

WebSockets are a different beast. Serialize writes or break the connection.

Real-world WebSockets: concurrency and safety

WebSockets often need to read and write concurrently. The gorilla/websocket library is not safe for concurrent writes. If two goroutines call WriteMessage at the same time, the library may panic or corrupt the frame. You must serialize writes using a mutex or a channel.

Here is a client struct that handles concurrent writes safely.

// Client represents a connected WebSocket user with safe concurrent write access.
type Client struct {
    conn *websocket.Conn
    // mu protects concurrent writes to the connection.
    mu sync.Mutex
    // sendChan buffers outgoing messages to serialize writes.
    sendChan chan []byte
}

// NewClient creates a client and starts a writer goroutine.
func NewClient(conn *websocket.Conn) *Client {
    c := &Client{
        conn:     conn,
        sendChan: make(chan []byte, 256),
    }
    // Start a dedicated goroutine to handle writes, preventing lock contention.
    go c.writePump()
    return c
}

// writePump reads from the channel and writes to the WebSocket connection.
func (c *Client) writePump() {
    for message := range c.sendChan {
        c.mu.Lock()
        // WriteMessage is not safe for concurrent use; the lock ensures serialization.
        err := c.conn.WriteMessage(websocket.TextMessage, message)
        c.mu.Unlock()
        if err != nil {
            break
        }
    }
}

The Client struct holds the connection, a mutex, and a buffered channel. NewClient creates the client and starts a writePump goroutine. The channel buffers messages to prevent blocking the sender. The writePump reads from the channel and writes to the connection. The mutex protects the write operation. The receiver name (c *Client) follows Go convention: one or two letters matching the type.

The worst goroutine bug is the one that never logs. Ensure writePump exits when the channel closes.

Pitfalls and errors

SSE and WebSockets have distinct failure modes. Understanding these prevents subtle bugs in production.

If you forget to flush in SSE, the client sees nothing until the handler returns. The browser waits for the response to complete. You get a hanging request instead of a stream. The compiler does not catch this. You must call flusher.Flush() after every write.

If you miss the Content-Type header, the browser treats the response as HTML. You get a blank page or garbled text. The browser console shows no error, but the EventSource never fires events.

Proxies and load balancers often kill idle connections. SSE streams can appear idle if no data is sent for a while. You must send periodic keep-alive messages. An empty data line or a comment : ping\n\n resets the timeout. If you don't, the proxy drops the connection, and the client sees a disconnect.

WebSockets have backpressure issues. If the server writes faster than the client reads, the internal buffer fills. WriteMessage blocks until space is available. If the client is slow or disconnected, the writer goroutine blocks forever. You must handle this with timeouts or by checking the connection state.

Concurrent writes to a WebSocket connection cause panics or corrupted frames. The error manifests as websocket: close sent or garbled data on the client side. Always serialize writes. If you write from multiple goroutines without a mutex or channel, the program crashes under load.

If you forget to close the connection, you leak file descriptors. The server runs out of resources eventually. The error appears as too many open files in logs. Always use defer conn.Close().

Trust gofmt. Argue logic, not formatting.

Decision: SSE vs WebSockets

Pick the tool that matches your traffic pattern. Don't use a sledgehammer for a nail.

Use Server-Sent Events when you need server-to-client streaming with minimal client code. Use Server-Sent Events when the client only sends data via standard HTTP POST requests. Use Server-Sent Events when you want automatic reconnection and event IDs handled by the browser. Use Server-Sent Events when you need to stream text data like logs, notifications, or price updates. Use WebSockets when you require full-duplex communication where the client sends frequent updates. Use WebSockets when you need binary data transfer with low overhead. Use WebSockets when the protocol requires the client to initiate actions that the server must react to immediately without a round-trip delay. Use standard HTTP polling when the latency requirements are loose and you want to avoid persistent connection overhead.

Where to go next