Implement Server-Sent Events in Go for Real-Time Updates
How to build a production-ready Server-Sent Events endpoint in Go with connection management, heartbeat pings, and graceful client disconnect handling
Note: This guide follows English-language naming conventions and terminology standards common in international development teams. Examples use English identifiers and comments to maximize compatibility across codebases and tooling.
Implement Server-Sent Events in Go for Real-Time Updates
Server-Sent Events provide a lightweight, uni-directional channel for pushing real-time updates from server to client over HTTP. Unlike WebSockets, SSE uses standard HTTP connections, requires no protocol upgrade, and automatically handles reconnection through the browser’s built-in EventSource API.
When to Use This
- You need to push notifications, logs, or live metrics to browsers
- The server is the only sender; clients only receive (no bi-directional chat)
- You want to use existing HTTP infrastructure (load balancers, CDNs)
Prerequisites
- Go 1.21+ installed
- Basic understanding of HTTP streaming and goroutines
Solution
1. Basic SSE Handler
// handlers/sse.go
package handlers
import (
"fmt"
"net/http"
"time"
)
type Event struct {
ID string
Type string
Data string
Retry int
}
func (e Event) String() string {
var result string
if e.ID != "" {
result += fmt.Sprintf("id: %s\n", e.ID)
}
if e.Type != "" {
result += fmt.Sprintf("event: %s\n", e.Type)
}
if e.Retry > 0 {
result += fmt.Sprintf("retry: %d\n", e.Retry)
}
result += fmt.Sprintf("data: %s\n\n", e.Data)
return result
}
func SSEHandler(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", "*")
flusher, ok := w.(http.Flusher)
if !ok {
http.Error(w, "Streaming unsupported", http.StatusInternalServerError)
return
}
ticker := time.NewTicker(2 * time.Second)
defer ticker.Stop()
clientGone := r.Context().Done()
for {
select {
case <-clientGone:
return
case <-ticker.C:
event := Event{
ID: fmt.Sprintf("%d", time.Now().Unix()),
Type: "ping",
Data: `{"timestamp": ` + fmt.Sprintf("%d", time.Now().Unix()) + `}`,
}
fmt.Fprint(w, event.String())
flusher.Flush()
}
}
}
2. Hub-Based Connection Management
// sse/hub.go
package sse
import (
"sync"
)
type Hub struct {
clients map[chan Event]bool
mu sync.RWMutex
}
func NewHub() *Hub {
return &Hub{clients: make(map[chan Event]bool)}
}
func (h *Hub) Subscribe() chan Event {
ch := make(chan Event, 10)
h.mu.Lock()
h.clients[ch] = true
h.mu.Unlock()
return ch
}
func (h *Hub) Unsubscribe(ch chan Event) {
h.mu.Lock()
delete(h.clients, ch)
h.mu.Unlock()
close(ch)
}
func (h *Hub) Broadcast(event Event) {
h.mu.RLock()
defer h.mu.RUnlock()
for ch := range h.clients {
select {
case ch <- event:
default:
// Channel full, drop event for this client
}
}
}
3. Production Handler with Heartbeat
// handlers/events.go
func EventStream(hub *sse.Hub) http.HandlerFunc {
return func(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
}
client := hub.Subscribe()
defer hub.Unsubscribe(client)
heartbeat := time.NewTicker(30 * time.Second)
defer heartbeat.Stop()
clientGone := r.Context().Done()
// Send initial connection event
fmt.Fprintf(w, "event: connected\ndata: %s\n\n", `{"status": "ok"}`)
flusher.Flush()
for {
select {
case <-clientGone:
return
case event := <-client:
fmt.Fprint(w, event.String())
flusher.Flush()
case <-heartbeat.C:
fmt.Fprint(w, ": heartbeat\n\n")
flusher.Flush()
}
}
}
}
4. Client-Side EventSource
// client.js
const evtSource = new EventSource('/api/events');
evtSource.addEventListener('connected', (e) => {
console.log('Connected:', JSON.parse(e.data));
});
evtSource.addEventListener('price-update', (e) => {
const update = JSON.parse(e.data);
document.getElementById('price').textContent = update.price;
});
evtSource.onerror = (err) => {
console.error('SSE error:', err);
// Browser auto-reconnects with exponential backoff
};
// Cleanup on page unload
window.addEventListener('beforeunload', () => {
evtSource.close();
});
How It Works
- HTTP Stream sends events as text/plain with
text/event-streamcontent type - Event Format uses
data:,event:,id:, andretry:fields per line - Browser Reconnection is automatic with last-event-id tracking
- Heartbeat Comments (
: ping) keep connections alive through proxies
Production Considerations
- Run SSE endpoints behind HTTP/2 capable load balancers for multiplexing
- Use Redis Pub/Sub to broadcast across multiple Go server instances. See Real-Time Notifications for Redis pub/sub patterns.
- Limit connections per client IP to prevent resource exhaustion
- Set appropriate write timeouts higher than standard REST endpoints
Common Mistakes
- Forgetting to call
Flush()after each event - Not handling client disconnect, leaving goroutines running
- Missing
Cache-Control: no-cache, causing proxies to buffer events
FAQ
Q: How does SSE compare to WebSockets? A: SSE is simpler for server-to-client push. Use WebSockets when you need bi-directional communication or binary data.
Q: Can SSE work through corporate proxies? A: Yes, but some proxies have aggressive timeouts. Send heartbeat comments every 30 seconds to keep connections open.
Q: What is the maximum number of concurrent SSE connections? A: Browser limit is 6 connections per domain. Use HTTP/2 or a shared connection to avoid this.
How do I handle client reconnection with Last-Event-ID?
The Last-Event-ID HTTP header is sent by the browser when an SSE connection drops and the client reconnects. On the server, read this header and replay any events with IDs greater than the last received one. Assign sequential IDs to events using the id: field in the SSE format. Store recent events in an in-memory ring buffer (e.g., last 100 events per channel) so reconnected clients can catch up without missing messages.
Should I use SSE or WebSocket for real-time updates?
Use SSE for server-to-client only streams (notifications, live feeds, dashboards). SSE is simpler: it uses standard HTTP, supports auto-reconnection, and works through proxies with minimal configuration. Use WebSocket when you need bidirectional communication (chat, collaborative editing, gaming). SSE has a browser limit of 6 concurrent connections per domain over HTTP/1.1, but HTTP/2 removes this limit.
How do I broadcast SSE to multiple clients in Go?
Maintain a map of connected clients, each with their own channel. When broadcasting, iterate over the map and send the event to each client’s channel using a non-blocking send (select with default case). Remove disconnected clients from the map on close. For large fan-out, use a pub/sub broker like Redis Pub/Sub so multiple Go processes can share the broadcast load.
How do I test SSE endpoints in Go?
Use httptest.NewServer to start the handler in-process. Connect with an SSE client (e.g., eventsource Go package or a raw HTTP client that reads the response body line by line). Assert that events arrive in order with correct data. For load testing, open many concurrent connections and measure event latency. Use context.WithTimeout to cancel long-running tests.
How do I secure SSE endpoints with authentication?
Pass auth tokens as query parameters (?token=...) since SSE connections cannot set custom headers from the browser EventSource API. Validate the token server-side before registering the client. For production, use short-lived tokens and rotate them. Alternatively, use cookies for auth (the browser sends cookies with SSE requests automatically). For cross-origin SSE, configure CORS headers on the SSE endpoint.
How do I handle SSE connection cleanup in Go?
Use context.Context to propagate cancellation. When the HTTP handler returns, the request context is cancelled — listen for <-ctx.Done() in your event loop and close the flusher. Remove the client from the connection map inside a defer block to ensure cleanup runs even on panic. Set a write timeout on each flush to detect stale connections. Run a background goroutine that periodically checks for dead connections and removes them.
How do I compress SSE responses in Go?
Enable gzip compression with middleware.Compress from chi or gin’s Gzip middleware. SSE responses benefit from compression when events contain repetitive JSON payloads. Set Content-Encoding: gzip and flush after each compressed chunk. Be aware that compression adds CPU overhead per event — benchmark with realistic payload sizes to determine if it improves throughput for your use case.
How do I handle SSE behind a load balancer?
Use sticky sessions (session affinity) so a client always connects to the same backend instance. Configure your load balancer (ALB, nginx, HAProxy) with long timeouts (e.g., 1 hour) to prevent premature connection drops. Disable response buffering in nginx: proxy_buffering off; and proxy_cache off;. For multi-instance broadcasting, use Redis Pub/Sub to fan events to all backend instances, each maintaining its own SSE connections.
How do I implement SSE event IDs for replay?
Assign a monotonically increasing ID to each event using the id: field in the SSE format. Store events in a ring buffer keyed by ID. When a client reconnects with Last-Event-ID: 42, replay events 43+ from the buffer. Set a TTL on stored events (e.g., 5 minutes) to limit memory usage. For events beyond the buffer window, return a 204 No Content and let the client decide whether to start fresh or show a reconnection message.
Related Resources
WebSocket Authentication and Security Patterns
How to authenticate WebSocket connections, implement token validation, and handle authorization for real-time messaging in production
RecipeBuild Real-Time APIs with WebSockets on Serverless
How to implement bidirectional real-time communication using WebSockets with AWS API Gateway, Lambda, DynamoDB, and what works in connection management.
RecipeGo REST API with Gin and Middleware
Build production-ready REST APIs in Go using the Gin framework with custom middleware for logging, authentication, validation, and error handling
RecipeBuild a Bidirectional Chat with WebSocket and Node.js
How to build a real-time bidirectional chat application using WebSocket with room-based messaging, presence tracking, and message persistence