Selecting between bidirectional connections and unidirectional streams isn't just a technology preference; it's a fundamental trade-off in system topology and resource cost.
What We're Building
We are designing a notification endpoint for a microservice mesh. This service ingests telemetry data from distributed sensors and pushes critical alerts to frontend clients. The architecture must support millions of concurrent connections without exhausting server memory. We evaluate the trade-off between bidirectional WebSockets and unidirectional Server-Sent Events to determine which fits this use case.
Step 1 — Define Directionality
The first decision involves data flow. WebSockets require two-way communication for full-duplex traffic, while Server-Sent Events (SSE) are strictly server-to-client. If your frontend only needs to receive updates, SSE is lighter. If the client must also send commands, WebSockets are mandatory.
Consider this Go implementation defining the endpoint behavior.
// SSE Handler
func (s *Server) HandleSSE(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")
ticker := time.NewTicker(1 * time.Second)
for range ticker.C {
s.publish(w, "event", "tick", "data", "sensor-1")
}
}
Rationale: SSE headers are significantly smaller than WebSocket handshake headers, reducing initial latency for broadcast scenarios.
Step 2 — Manage Connection Lifecycle
Real-time protocols require keeping connections alive. SSE handles connection teardown gracefully with HTTP 204 responses, but WebSockets need explicit ping/pong logic. You must implement a heartbeat mechanism to distinguish idle clients from disconnected ones.
Use a Goroutine to manage the active state for long-lived sessions.
func (s *Server) TrackSession(conn net.Conn) {
ticker := time.NewTicker(30 * time.Second)
go func() {
for range ticker.C {
if err := conn.Write([]byte("PONG\n")); err != nil {
conn.Close()
}
}
}()
}
Rationale: Writing PONG data ensures the client knows the server is active before the idle timeout triggers a disconnect.
Step 3 — Implement Backpressure Handling
If the server floods the client with data faster than the network can handle it, the browser will drop the connection. SSE relies on the data payload size and the browser's buffer limits. You must monitor queue depth before pushing.
if queueSize > maxBufferLimit {
stopSending = true
w.WriteHeader(http.StatusTooManyRequests)
}
Rationale: Dropping the connection early signals the client to retry later, preventing memory exhaustion on the server process.
Step 4 — Plan for Client Reconnection
Clients lose network stability. SSE supports automatic reconnection via Last-Event-ID. WebSockets require custom heartbeat logic to reconnect. The browser handles SSE connection drops and retries automatically, whereas WebSockets need state restoration logic.
func (s *Server) OnConnect(w http.ResponseWriter, r *http.Request) {
lastEventID := r.Header.Get("Last-Event-ID")
if lastEventID != "" {
s.resyncStream(w, lastEventID)
}
}
Rationale: The Last-Event-ID header allows the client to resume the stream exactly where it left off without losing event order.
Key Takeaways
- Directionality dictates protocol choice; use SSE for push-only scenarios to save memory and reduce handshake overhead.
- Browser Support for SSE is near universal in modern browsers, while WebSockets often require fallback logic for legacy environments.
- Bandwidth costs are lower with SSE because it uses standard HTTP long-polling mechanisms rather than maintaining a persistent TCP tunnel for bi-directional data.
- State Management is simpler with SSE since the server does not need to track the client's outgoing state.
- Load Balancing is friendlier for SSE because it requires no session stickiness, whereas WebSockets often require sticky sessions or stateless middleware configuration.
- Client Control is lost with SSE, but this is acceptable when the client passively consumes data rather than actively querying it.
What's Next?
- Explore WebSocket sub-protocols like WSS for encryption requirements in sensitive environments.
- Review your load balancer configuration to ensure it handles persistent connections correctly.
- Design a fallback mechanism that switches to WebSocket if SSE connection quality drops.
Further Reading
- Designing Data-Intensive Applications (Kleppmann) — Understands the fundamental trade-offs between streaming, batching, and persistence that apply here.
- A Philosophy of Software Design (Ousterhout) — Teaches you to choose the right abstraction level for system interfaces and communication boundaries.
Part of the Architecture Patterns series.
Top comments (0)