← Blog

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:

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 best argument for SSE is not that it is faster. It is that there is much less of it, and the part that would have been yours to maintain is already written.

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.

One operational detail: send a comment line every twenty or thirty seconds as a heartbeat, and make sure your load balancer's idle timeout is comfortably longer than that. Otherwise the balancer decides an idle stream is a dead stream and cuts it. Ours is set to five minutes.

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:

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.

Try it

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.