← 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.

Look at what the feature actually requires

Write down what a notification stream 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 nothing else, so there is no way to attach a bearer token. The workarounds are putting the token in the query string, which then sits in access logs, or reading the stream yourself with fetch and a stream reader.

We went with fetch. It costs you the free reconnect, so you write that part, but you keep ordinary header auth and you can share one token with the REST calls.

Old HTTP versions limit connections. Over HTTP/1.1 a browser allows about six connections per domain, and a held-open stream occupies one of them in every tab. Over HTTP/2 it stops mattering. If you are serving HTTP/2, and you almost certainly are, this is a historical footnote.

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.

This sounds like extra work and it removes 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.

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.