← back to posts

Why WebSockets Aren't Always the Best Choice for Real-Time Communication

Every developer’s first instinct for real-time features is WebSockets. Chat? WebSockets. Notifications? WebSockets. Live dashboard? WebSockets. But after running WebSocket-based systems in production, I’ve learned that they’re often the wrong choice — and the costs only become obvious at scale.

The Hidden Cost of Persistent Connections

A WebSocket is a persistent, stateful TCP connection. That sounds fine until you do the math.

Each connected client holds:

  • A TCP socket (file descriptor)
  • Memory for send/receive buffers (~8-16 KB per connection)
  • Application-level state (user session, subscriptions, heartbeat timers)
  • A goroutine or thread (depending on your runtime)
10,000 users × 16 KB buffers = 160 MB just for buffers
10,000 users × goroutine stack (8 KB) = 80 MB for goroutines
+ session state, heartbeat timers, subscription maps...

That's 300-500 MB for 10K connections doing nothing.

Now scale to 100K users. You’re burning gigabytes of memory on idle connections. With HTTP, those users would connect, get their response, and free all resources in milliseconds.

Scaling WebSockets Is Fundamentally Harder

HTTP is stateless. Any server can handle any request. Load balancing is trivial — round-robin, least connections, whatever.

WebSockets are stateful. A client is bound to a specific server for the lifetime of the connection. This breaks almost every scaling pattern you’re used to.

Sticky Sessions Are Required

Your load balancer must route each client to the same backend server. This means:

  • Uneven load distribution (some servers get more long-lived connections)
  • Harder rolling deployments (you can’t just drain a server — clients are connected)
  • Server failures disconnect all clients on that server simultaneously
                    Load Balancer
                    (sticky sessions)
                   /       |       \
              Server A   Server B   Server C
              3,000       8,000     1,200
              conns       conns     conns
              ← uneven distribution →

Cross-Server Communication

If User A is connected to Server A and User B is connected to Server B, how does User A send a message to User B?

You need a pub/sub layer — Redis, NATS, Kafka — between your WebSocket servers. Every message now has an extra hop:

Client A → Server A → Redis pub/sub → Server B → Client B

That’s infrastructure you don’t need with HTTP. And that pub/sub layer itself needs to be highly available, adding more operational overhead.

// Every WebSocket server must subscribe to relevant channels
func (s *WSServer) subscribeToRedis() {
    pubsub := s.redis.Subscribe(ctx, "chat:*", "notifications:*")

    for msg := range pubsub.Channel() {
        // Find local connections that care about this message
        targetConns := s.registry.GetSubscribers(msg.Channel)
        for _, conn := range targetConns {
            conn.WriteMessage(websocket.TextMessage, []byte(msg.Payload))
        }
    }
}

Autoscaling Doesn’t Work Well

HTTP services autoscale beautifully — spin up more instances when requests per second increase, scale down when traffic drops.

WebSocket servers can’t scale down easily. You can’t terminate a server that has 5,000 active connections without disrupting all those users. Scaling up helps, but scaling down requires graceful connection migration — which most teams never implement.

Scale up:   Easy — new connections go to new servers
Scale down: Hard — existing connections can't be moved
Result:     You end up over-provisioned, paying for idle servers

The Server Cost Reality

Let’s compare serving 50,000 concurrent users:

WebSocket approach:

  • 50K persistent connections
  • ~4-6 servers (8K connections per server, conservatively)
  • Each server: 4 vCPU, 8 GB RAM minimum
  • Redis cluster for pub/sub: 3 nodes
  • Always running, 24/7, regardless of message frequency
  • Monthly cost: ~$800-1,200

SSE + HTTP POST approach:

  • SSE connections are lighter (unidirectional, no upgrade handshake overhead)
  • Client-to-server messages go through normal HTTP (stateless, load-balanced)
  • Easier to scale each direction independently
  • Monthly cost: ~$400-600

Polling approach (for low-frequency updates):

  • No persistent connections at all
  • Scales with standard HTTP infrastructure
  • Serverless-friendly (pay per request)
  • Monthly cost: ~$50-200 (depending on poll frequency)

The difference is dramatic. And for many features — notifications that arrive a few times per hour, dashboards that update every 30 seconds — polling or SSE is more than adequate.

Connection Management Is an Operational Nightmare

WebSocket connections drop. Constantly. Mobile users lose signal. Laptops go to sleep. Network switches restart. Each disconnection needs:

  1. Detection — heartbeat/ping-pong mechanism
  2. Cleanup — remove from subscription maps, release resources
  3. Reconnection — client reconnects, re-authenticates, re-subscribes
  4. State reconciliation — what messages did the client miss while disconnected?
// You need all of this for every WebSocket service
type ConnectionManager struct {
    connections  map[string]*websocket.Conn
    mu           sync.RWMutex
    heartbeat    time.Duration
    missedPings  map[string]int
    maxMissed    int
}

func (cm *ConnectionManager) startHeartbeat(userID string, conn *websocket.Conn) {
    ticker := time.NewTicker(cm.heartbeat)
    defer ticker.Stop()

    for range ticker.C {
        if err := conn.WriteControl(
            websocket.PingMessage, nil,
            time.Now().Add(5*time.Second),
        ); err != nil {
            cm.handleDisconnect(userID)
            return
        }
    }
}

func (cm *ConnectionManager) handleDisconnect(userID string) {
    cm.mu.Lock()
    delete(cm.connections, userID)
    delete(cm.missedPings, userID)
    cm.mu.Unlock()

    // Clean up subscriptions
    cm.subscriptionManager.RemoveAll(userID)

    // Queue missed messages for when they reconnect
    cm.missedMessageStore.MarkDisconnected(userID, time.Now())
}

With HTTP, none of this exists. The protocol handles connection lifecycle for you.

When WebSockets Actually Make Sense

I’m not saying never use WebSockets. They’re the right choice when:

  • Bidirectional, low-latency communication is critical — multiplayer games, collaborative editing (Google Docs-style), live trading platforms
  • Message frequency is very high — real-time gaming (60+ updates/second), live audio/video signaling
  • Both client and server push frequently — interactive applications where the overhead of HTTP per message is genuinely a bottleneck

The Alternatives That Usually Win

Server-Sent Events (SSE)

For server-to-client streaming, SSE is simpler and cheaper:

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")

    flusher := w.(http.Flusher)

    for {
        select {
        case msg := <-userChannel:
            fmt.Fprintf(w, "data: %s\n\n", msg)
            flusher.Flush()
        case <-r.Context().Done():
            return
        }
    }
}

SSE advantages over WebSockets:

  • Works over standard HTTP (no upgrade handshake)
  • Automatic reconnection built into the browser API
  • Works through HTTP/2 multiplexing (shares a single TCP connection)
  • Compatible with standard HTTP infrastructure (CDNs, proxies, load balancers)
  • Client-to-server communication uses normal HTTP POST (stateless, scalable)

For notifications, live feeds, dashboards, stock tickers — SSE is almost always the better choice.

Long Polling

For infrequent updates where SSE feels like overkill:

func longPollHandler(w http.ResponseWriter, r *http.Request) {
    ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
    defer cancel()

    select {
    case msg := <-userChannel:
        json.NewEncoder(w).Encode(msg)
    case <-ctx.Done():
        w.WriteHeader(http.StatusNoContent) // No updates, client retries
    }
}

Simple, stateless, works everywhere, scales with standard infrastructure.

Short Polling

For updates that are fine being 5-30 seconds stale:

// Client-side: poll every 10 seconds
setInterval(async () => {
  const res = await fetch('/api/notifications?since=' + lastTimestamp);
  const data = await res.json();
  if (data.length > 0) {
    updateUI(data);
    lastTimestamp = data[data.length - 1].timestamp;
  }
}, 10000);

At 10-second intervals, 50K users generate ~5K requests/second. A single well-optimized HTTP server handles that easily. No persistent connections, no state management, no pub/sub layer.

My Decision Framework

Does the client need to send frequent messages to the server?
  └─ No  → SSE (server push) + HTTP POST (client messages)
  └─ Yes → How frequent?
            └─ < 1/second  → SSE + HTTP POST
            └─ > 1/second  → Do both sides push at high frequency?
                              └─ No  → SSE + HTTP POST
                              └─ Yes → WebSockets (finally)

Most real-time features fall into the first category. Notifications, live feeds, order status updates, dashboard metrics — these are all server-to-client streams where the client rarely sends anything back.

The Real Problem

The real problem isn’t WebSockets as a technology. It’s that developers reach for them by default without calculating the operational cost. A feature that could be built with SSE in a day, deployed on existing infrastructure, and scaled trivially — instead becomes a WebSocket service that needs its own deployment, its own scaling strategy, a pub/sub layer, connection management, reconnection logic, and a team that understands stateful services.

Choose the simplest protocol that meets your latency requirements. For most real-time features, that isn’t WebSockets.