How to Add Real-Time Subscriptions Without a Websocket Server You Regret

    6 min read3 approaches compared

    Real-time features look like a transport problem and turn out to be a state problem. Opening a WebSocket is twenty lines. What takes the time is everything the connection implies: what happens to messages sent while a client was disconnected, how a second server instance knows to notify a socket it doesn't hold, and how you tell whether someone closed the tab or just went through a tunnel.

    Most teams need far less than a full bidirectional socket. Before reaching for one, it's worth knowing that server-sent events cover the majority of "live updates" requirements at a fraction of the operational cost.

    Direction of traffic decides the transport

    The single question that eliminates most options: does the client need to push, or only receive? Answering it honestly usually saves you a WebSocket layer.

    • Server pushes, client only listens (notifications, live dashboards, job progress): server-sent events. Plain HTTP, auto-reconnects, works through every proxy.
    • Both directions with low latency (chat, collaborative editing, multiplayer): WebSockets, and accept the operational cost that comes with them.
    • Updates driven by database rows changing: hosted realtime (Supabase, Firebase) so you don't build the change-capture pipeline yourself.
    • Updates every 30 seconds or slower: polling. It is not embarrassing, it is cacheable, stateless, and it survives deploys.
    • Whatever you pick: assume every client will disconnect and need to resync. Design that path first, not last.

    The approaches

    Server-sent events (SSE)

    // best for: Live updates flowing one way, which is most real-time requirements.

    • Express.js
    • Next.js
    • FastAPI
    • Django
    • Laravel
    • NestJS
    • Go

    SSE is an HTTP response that never ends. The server holds the connection open and writes `data:` frames; the browser's `EventSource` handles reconnection automatically, including sending back a `Last-Event-ID` header so you can resume from where the client left off. That resume mechanism is genuinely valuable and it's built into the protocol rather than something you invent.

    Because it's ordinary HTTP, it passes through corporate proxies and load balancers that mangle WebSocket upgrades, and it needs no separate server or port. Authentication is your existing cookie or header.

    The constraint people hit is the browser's per-domain connection limit over HTTP/1.1 — six connections, and an open SSE stream occupies one. Several tabs and the app stops loading anything. Over HTTP/2 this evaporates, so in practice SSE requires HTTP/2, which any modern host gives you.

    typescript
    res.writeHead(200, {
      "Content-Type": "text/event-stream",
      "Cache-Control": "no-cache, no-transform", // no-transform stops proxy buffering
      Connection: "keep-alive",
      "X-Accel-Buffering": "no",                 // nginx specifically
    });
    
    // The id lets EventSource resume via Last-Event-ID after a drop.
    const send = (id: string, data: unknown) =>
      res.write(`id: ${id}\ndata: ${JSON.stringify(data)}\n\n`);
    
    // Without a periodic comment, idle connections get reaped
    // by proxies and load balancers at 30-60s.
    const ping = setInterval(() => res.write(": ping\n\n"), 25_000);
    req.on("close", () => clearInterval(ping));

    The gotcha

    Reverse proxies buffer responses by default, so your events sit in nginx's buffer and arrive in a burst minutes later — the feature looks broken in production while working perfectly on localhost. You need `X-Accel-Buffering: no` for nginx and `Cache-Control: no-transform` to stop intermediaries from batching.

    Hosted realtime (Supabase Realtime, Firebase, Ably, Pusher)

    // best for: Teams who want database changes to reach clients without building change capture.

    • Supabase
    • Firebase
    • Convex
    • Appwrite
    • Nhost

    Supabase Realtime reads the Postgres write-ahead log and broadcasts row changes to subscribed clients, filtered by the same row-level security policies that govern normal queries. That last part is the real value: a subscription can't leak rows the user couldn't have selected, because it's the same policy engine.

    Firebase's model is different — the database *is* the realtime layer, and every read is potentially a live query. That's why Firebase feels effortless for this and why the billing surprises people: a listener attached to a collection bills a document read per document, every time any of them changes.

    Pusher and Ably are transport-only: you publish, they fan out. No database coupling, which suits you when the events aren't row changes.

    The gotcha

    Supabase Realtime broadcasts row changes, not the result of your query. A subscription filtered on `status = 'open'` gets the UPDATE that changes a row to `closed` too — the row still matches the channel filter at the moment it changes. Reconcile client-side, or you'll leave rows on screen that no longer belong there.

    WebSockets you operate

    // best for: Genuinely bidirectional, low-latency features: chat, cursors, multiplayer.

    • Express.js
    • NestJS
    • Go
    • Elixir
    • Redis
    • Kubernetes

    When the client must push with low latency, you need a socket. The code to accept one is trivial; the architecture around it is not.

    The defining constraint is that a WebSocket is stateful and pinned to one process. With more than one instance behind a load balancer, a message that must reach a user connected to instance B cannot be sent from instance A. The standard answer is a Redis pub/sub backplane: every instance subscribes, publishes go to Redis, and each instance forwards to its own sockets.

    The second constraint is deploys. Every rolling deploy drops every connection at once, and all clients reconnect simultaneously — a self-inflicted thundering herd. Jittered reconnect backoff on the client is mandatory, not a refinement.

    Elixir's Phoenix Channels deserve a mention: the BEAM's process model makes this whole category dramatically easier, and it's a legitimate reason to pick the stack.

    The gotcha

    Authentication. The browser's WebSocket API can't set headers, so you can't send an `Authorization` header on the handshake. Teams put the token in the query string, where it lands in every access log. Authenticate the upgrade with the session cookie, or send the token as the first message and refuse to process anything else until it validates.

    Common pitfalls

    No resync after reconnect

    A client drops for eight seconds and misses three updates. On reconnect it resumes listening and its state is now permanently wrong, with no error anywhere. Every real-time client needs to refetch current state on reconnect, or resume from a sequence ID. Assume disconnection is routine, because it is.

    Broadcasting to everyone and filtering on the client

    Sending all events to all clients and letting each one ignore what it doesn't need is both a data leak and a bandwidth problem — the data is in the payload regardless of whether the UI renders it. Filter server-side, per subscription.

    Presence treated as a boolean

    'Who is online' seems simple and is the hardest part of most real-time features. Closing a laptop lid doesn't send a disconnect, so users stay 'online' until a timeout. You need heartbeats and a TTL, and you have to accept that presence is eventually consistent.

    Unbounded fan-out

    A change to a popular record notifies 50,000 subscribers at once and the event loop stalls. Batch, throttle per subscriber, or push a lightweight invalidation signal and let clients refetch on their own schedule.

    Questions people actually ask

    Is polling ever the right answer?

    Frequently. If updates are useful within 30 seconds, polling is stateless, cacheable, survives deploys without reconnect storms, and needs no infrastructure. It is the correct engineering answer far more often than it's chosen.

    SSE or WebSockets?

    SSE unless the client needs to push. It's simpler, reconnects itself, resumes via Last-Event-ID, and traverses proxies that break WebSocket upgrades. Reach for WebSockets when bidirectional low latency is a real requirement.

    How do I scale WebSockets past one server?

    A pub/sub backplane, usually Redis. Each instance subscribes to the channels its connected clients care about and forwards messages to its own sockets. Sticky sessions help but don't remove the need — instances still can't reach each other's connections.

    Need someone who's done this before?

    Browse vetted developers who work with these tools day to day.

    Related guides