Skip to content
ExponaBLOG
GO PROGRAMMING & PRODUCTION APIS · DAY 059 / 100

Server-sent events in Go: a hands-on guide for engineers

Stream updates to browsers.

Expona Admin7 min read

Why Server-Sent Events Matter in Production

Real-time communication between server and browser has become table stakes for modern applications. When you need to push updates without polling, server-sent events in Go offers a lightweight, HTTP-native solution that works across firewalls and proxies. This guide walks through the essentials: setting the right headers, flushing after each event, respecting context cancellation, and disabling proxy buffering—the four pillars that separate working implementations from ones that mysteriously stall in production.

Server-sent events (SSE) are simpler than WebSockets when you only need unidirectional server-to-browser streaming. You'll use them for live dashboards, real-time notifications, chat message delivery, stock tickers, and progress updates on long-running jobs. Let's build production-ready implementations.

Set text/event-stream Headers

The HTTP spec requires specific headers for SSE to work correctly. Browsers parse the response only when Content-Type: text/event-stream is set; without it, they treat the response as plain text and never trigger the EventSource API. You'll also need Cache-Control: no-cache to prevent intermediaries from buffering your stream.

Here's a minimal example:

go
func handleEvents(w http.ResponseWriter, r *http.Request) {
    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", "*")

    // Event loop starts here
    for i := 0; i < 5; i++ {
        fmt.Fprintf(w, "data: message %d\n\n", i)
        time.Sleep(1 * time.Second)
    }
}

Three blank lines matter: the SSE spec uses \n\n to delimit events. data: is the field name; the browser's EventSource extracts everything after it. Notice we set Connection: keep-alive to keep the TCP connection open for subsequent events.

If you're serving multiple origins, add Access-Control-Allow-Origin carefully—using * works locally but tighten it in production. Better: use a whitelist and validate r.Header.Get("Origin").

Flush After Each Event

Go's http.ResponseWriter buffers output by default. If you send an event and immediately try to send another without flushing, the browser won't receive anything until the buffer fills or the connection closes. This is the most common trap in SSE implementations.

You must call Flush() on the underlying http.Flusher interface after every event:

go
func handleEvents(w http.ResponseWriter, r *http.Request) {
    w.Header().Set("Content-Type", "text/event-stream")
    w.Header().Set("Cache-Control", "no-cache")
    w.Header().Set("Connection", "keep-alive")

    flusher, ok := w.(http.Flusher)
    if !ok {
        http.Error(w, "Streaming unsupported", http.StatusInternalServerError)
        return
    }

    for i := 0; i < 10; i++ {
        fmt.Fprintf(w, "data: Update %d\n\n", i)
        flusher.Flush()  // Critical: push to browser immediately
        time.Sleep(2 * time.Second)
    }
}

Always check the assertion. Some test frameworks or proxy configurations don't support flushing, and you want a clear error rather than a silent hang. In production, every SSE handler should start with that Flusher check.

For structured data, you can send JSON:

go
type Update struct {
    ID        int       `json:"id"`
    Timestamp time.Time `json:"timestamp"`
    Message   string    `json:"message"`
}

func handleEvents(w http.ResponseWriter, r *http.Request) {
    w.Header().Set("Content-Type", "text/event-stream")
    w.Header().Set("Cache-Control", "no-cache")
    w.Header().Set("Connection", "keep-alive")

    flusher := w.(http.Flusher)
    
    for i := 0; i < 5; i++ {
        update := Update{
            ID:        i,
            Timestamp: time.Now(),
            Message:   fmt.Sprintf("Update number %d", i),
        }
        
        data, _ := json.Marshal(update)
        fmt.Fprintf(w, "data: %s\n\n", string(data))
        flusher.Flush()
        time.Sleep(1 * time.Second)
    }
}

The browser's EventSource will receive each event as a string you parse client-side. For more complex events, you can add an id: field so the browser auto-resumes from that point on reconnect.

Stop on ctx.Done()

SSE connections stay open for minutes or hours. If you ignore context cancellation, goroutines leak when clients disconnect or your server shuts down. Always check ctx.Done() in your event loop.

go
func handleEvents(w http.ResponseWriter, r *http.Request) {
    w.Header().Set("Content-Type", "text/event-stream")
    w.Header().Set("Cache-Control", "no-cache")
    w.Header().Set("Connection", "keep-alive")

    flusher := w.(http.Flusher)
    ctx := r.Context()

    eventChan := make(chan string, 1)
    go publishUpdates(ctx, eventChan)

    for {
        select {
        case <-ctx.Done():
            // Client disconnected or server is shutting down
            close(eventChan)
            return
        case event := <-eventChan:
            if event == "" {
                continue
            }
            fmt.Fprintf(w, "data: %s\n\n", event)
            flusher.Flush()
        }
    }
}

func publishUpdates(ctx context.Context, ch chan<- string) {
    ticker := time.NewTicker(2 * time.Second)
    defer ticker.Stop()

    counter := 0
    for {
        select {
        case <-ctx.Done():
            return
        case <-ticker.C:
            counter++
            ch <- fmt.Sprintf("server time: %s (count: %d)", time.Now().Format("15:04:05"), counter)
        }
    }
}

This pattern decouples event generation from HTTP response writing. The goroutine respects context cancellation, so no goroutine leaks. Use channels to push events from your business logic layer into the HTTP handler.

Disable Proxy Buffering

Many production deployments sit behind nginx, AWS ALB, or other proxies. By default, proxies buffer the response until it reaches a certain size or the connection closes. Your first event might sit in a buffer for 30 seconds while the client waits. You'll see this in the wild: events arrive in bursts instead of on time.

Add headers to disable buffering at the proxy layer:

go
func handleEvents(w http.ResponseWriter, r *http.Request) {
    w.Header().Set("Content-Type", "text/event-stream")
    w.Header().Set("Cache-Control", "no-cache")
    w.Header().Set("Connection", "keep-alive")
    
    // For nginx, disable buffering
    w.Header().Set("X-Accel-Buffering", "no")

    flusher := w.(http.Flusher)

    for i := 0; i < 5; i++ {
        fmt.Fprintf(w, "data: Event %d sent at %s\n\n", i, time.Now().Format("15:04:05"))
        flusher.Flush()
        time.Sleep(1 * time.Second)
    }
}

The X-Accel-Buffering: no header is nginx-specific and widely respected. For AWS ALB, ensure your target group's connection draining is set low and that your security groups allow keep-alive. Test your proxy's behavior: write a client that reads events and logs arrival times, then measure actual latency.

Common Mistakes

Forgetting to flush: The buffer fills slowly for small events, so your handler "works" in testing but stalls at scale. Always call flusher.Flush() after each event.

Ignoring context cancellation: A client closes the tab, but your handler's goroutine keeps running, consuming memory and resources. Always select on ctx.Done().

Missing Content-Type header: Browsers won't recognize the stream if the header isn't text/event-stream. The response sits there, seemingly hung, while JavaScript never fires the onmessage handler.

Not validating the Flusher interface: If you don't check that w implements http.Flusher, you'll get a runtime panic in test environments or behind certain proxies. Always assert and return an error.

Sending events without the trailing newlines: SSE requires \n\n to delimit events. Forgetting them causes the browser to wait for more data, treating multiple events as one.

Complete Production Example

Here's a realistic pattern for a real-time notification service:

go
type NotificationService struct {
    subscribers map[string]chan<- string
    mu          sync.RWMutex
}

func (ns *NotificationService) Subscribe(userID string, ctx context.Context, w http.ResponseWriter, r *http.Request) {
    flusher, ok := w.(http.Flusher)
    if !ok {
        http.Error(w, "Streaming not supported", http.StatusInternalServerError)
        return
    }

    w.Header().Set("Content-Type", "text/event-stream")
    w.Header().Set("Cache-Control", "no-cache")
    w.Header().Set("Connection", "keep-alive")
    w.Header().Set("X-Accel-Buffering", "no")

    eventChan := make(chan string, 10)
    ns.registerSubscriber(userID, eventChan)
    defer ns.unregisterSubscriber(userID, eventChan)

    for {
        select {
        case <-ctx.Done():
            return
        case event := <-eventChan:
            fmt.Fprintf(w, "data: %s\n\n", event)
            flusher.Flush()
        }
    }
}

func (ns *NotificationService) registerSubscriber(userID string, ch chan<- string) {
    ns.mu.Lock()
    defer ns.mu.Unlock()
    ns.subscribers[userID] = ch
}

func (ns *NotificationService) unregisterSubscriber(userID string, ch chan<- string) {
    ns.mu.Lock()
    defer ns.mu.Unlock()
    delete(ns.subscribers, userID)
    close(ch)
}

func (ns *NotificationService) Broadcast(userID, message string) {
    ns.mu.RLock()
    ch, exists := ns.subscribers[userID]
    ns.mu.RUnlock()
    
    if exists {
        select {
        case ch <- message:
        default:
            // Channel full, skip to avoid blocking
        }
    }
}

This pattern lets you broadcast notifications to specific users in real time, with proper cleanup when they disconnect. The buffered channel (size 10) prevents the Broadcast goroutine from blocking if the subscriber's read loop temporarily lags.

Key Takeaways

  • Set Content-Type: text/event-stream, Cache-Control: no-cache, and Connection: keep-alive headers so browsers and proxies treat the response correctly.
  • Call Flush() after every event to push data to the client immediately; never rely on automatic buffer draining.
  • Always respect ctx.Done() to clean up goroutines when clients disconnect or your server shuts down.
  • Add X-Accel-Buffering: no to disable proxy buffering, ensuring real-time delivery instead of bursts.
  • Use channels to decouple event generation from HTTP response writing, keeping your handler simple and testable.
  • In production, measure end-to-end latency through your proxy layer to catch buffering issues before they reach users.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles