Your notification inbox does not need WebSockets
Real-time means WebSockets, everyone says. For a feed the server writes and the client only reads, it is usually the wrong tool, and picking it buys you a week of infrastructure work you did not need.
Someone asks for a notification bell that updates without a refresh, and the reflex answer is WebSockets. Real-time means WebSockets. That is how everyone says it.
For a notification inbox it is usually the wrong tool, and picking it costs you a week of infrastructure work you did not need to do.
A notification stream is a trickle in one direction
Write down what the feature actually does, honestly:
- The server sends. The client does not.
- Messages are rare. A busy user might get a few an hour.
- They are small, and they are text.
- Nothing is urgent to the millisecond. A second late is invisible.
- Missing one is not acceptable, so the data must live somewhere real anyway.
That is a one directional trickle of small text messages. WebSockets is a bidirectional binary capable protocol built for a conversation. You would be buying a great deal of capability to push three JSON objects an hour in one direction.
What the extra capability costs
WebSockets is not HTTP. It starts as HTTP and then upgrades away from it, and everything downstream has to understand that.
Your infrastructure has to cooperate. Load balancers, proxies, CDNs and API gateways all need explicit support for the upgrade. Plenty of them handle it, but each one is a setting you have to find and a failure mode you have to learn.
You lose the HTTP toolbox. Ordinary request headers, the usual auth middleware, compression, and the debugging tools you already know. What you get back is a socket and the job of inventing message framing on top of it.
You write the reliability yourself. Heartbeats to notice a dead connection. Reconnect with backoff. Resuming from where you left off. None of that is hard, exactly, but all of it is yours now.
Server-sent events do this already
An SSE stream is an ordinary HTTP response that never ends. The content type is
text/event-stream, and the server writes lines into it as things
happen.
// the whole protocol
event: notification
data: {"id":"01J...","title":"Your order shipped"}
event: notification
data: {"id":"01J...","title":"Invoice paid"}
Because it is just HTTP, everything that already works keeps working. Your load balancer needs no new setting. Your proxy does not need to understand an upgrade. You can look at the stream with curl.
The reconnection logic is in the browser rather than in your code. If the connection drops it reconnects on its own, and it tells the server the last event id it saw so you can resume rather than replay.
The honest drawbacks
Three, and you should know them before choosing.
It only goes one way. If your client needs to send a constant stream of updates, SSE is not your transport. For an inbox this is a non-issue, because marking something read is a normal API call and you already have an API.
The browser's built in client cannot send headers. This is the one that
actually bit us. EventSource takes a URL and an options object with
one property in it, and that property is not headers:
// what you want, and cannot have new EventSource("/v1/notifications/stream", { headers: { Authorization: `Bearer ${token}` } // silently ignored });
So there is no way to attach a bearer token. You either put it in the query string, where it
lands in every access log and proxy trace on the way, or you read the stream yourself with
fetch and a stream reader.
We went with fetch. That hands back the free reconnect, so you write that part yourself, but you keep ordinary header auth and one token shared with the REST calls. It is maybe sixty lines. The query string version is zero lines and a credential in your logs.
Old HTTP versions limit connections. Over HTTP/1.1 a browser allows six connections per domain, and a held-open stream occupies one of them in every tab, so four tabs of your app leave two for everything else. Over HTTP/2 it stops mattering, and if you terminate TLS at anything modern you are already serving HTTP/2.
The decision that matters more than the transport
Whichever you pick, do not let the stream be the source of truth.
Our notifications are written to the database first. Delivering them live is a best effort layer on top. The client opens the stream, and it also fetches the list over REST the normal way.
It sounds like duplicated effort. It deletes an entire category of problem. A dropped connection stops being an incident. A missed message during a deploy stops being a bug. Reconnect, refetch, and you are correct again. You never have to guarantee that a socket saw everything, because nothing depends on it having done so.
Get that right and the transport becomes a detail you could change later. Get it wrong and no amount of WebSocket sophistication saves you, because now a network blip loses data.
When you really do want WebSockets
Not never. Reach for it when the client genuinely talks back as often as the server does:
- collaborative editing, where every keystroke goes up
- presence and typing indicators
- chat, once it is a real chat rather than a feed
- anything binary, or anything measured in milliseconds
A notification bell is none of those. It is a feed the server writes and the client reads.
And do not poll
The third option is asking the server every thirty seconds whether anything happened. It is the easiest to build and the most expensive to run.
Ten thousand users polling every thirty seconds is twenty thousand requests a minute, almost all answering "nothing new". One held connection per user replaces all of it, and it is faster for the user as well, because they see the message when it happens instead of up to thirty seconds later.
Polling is the option that looks cheap because the cost is spread across every minute of every day instead of showing up in the work you do this week. Ten thousand users is our example, not yours, so work out what your own concurrency and interval cost you before taking any of this as advice. We put a calculator up for that.
One API for in-app, email and push
Per-tenant branding, per-contact preferences, and a real-time inbox with no polling, plus a self-host option so the exit stays open. Free tier, no card.