Solution
How to add realtime without four sockets per browser tab
Realtime features arrive one at a time, each built sensibly by somebody solving their own problem, and the result is four persistent connections per tab and four reconnection strategies that behave differently.
The short answer
Open exactly one stream per application and multiplex every event type over it. Give every event a monotonic id so a reconnect can say what it last saw and receive the gap. And decide once whether the stream is a notification or the data itself, because mixing those two is what makes realtime state impossible to reason about.
How the sprawl happens
Nobody decides to open four connections. It accretes.
Notifications ship first and open a stream. Two months later a dashboard needs live figures, and opening a second stream is the smallest change. Then a long running import needs a progress bar, so that is a third. Then presence.
Each was a reasonable local decision. The result is four persistent connections per tab, four reconnection behaviours, four authentication paths, and a server holding four times the connections it needs.
The fix is one decision made early: one stream per application, always, with events multiplexed by type. Retrofitting it later means touching every consumer, which is why it is worth deciding on the first realtime feature.
Step 1. One connection, typed events
A single stream, opened once at application start, carrying every event type. Components subscribe to a type through a client side bus rather than opening anything themselves.
GET /events one stream, authenticated like any request
event: notification.created {...}
event: invoice.updated {...}
event: job.progress {...}
event: presence.changed {...}
On the client, one connection manager owns the transport. Components ask it for a type and never touch the underlying stream. That indirection is what stops the third feature from opening a second connection, because doing so is now clearly more work than not doing so.
Verify: open the application, then count the open connections in the network panel. The number is one, on every page, forever.
Step 2. Decide what an event is, and be consistent
Two models, and the mistake is mixing them for the same entity.
Signal. The event says an entity changed. The client refetches through the ordinary API.
event: invoice.updated { id: 4821 }
-> client refetches /invoices/4821
Simple, always correct, and it reuses your existing authorisation and serialisation. Costs a round trip.
Payload. The event carries the new state, and the client applies it directly.
event: invoice.updated { id: 4821, status: 'PAID', balance: 0, version: 12 }
Faster and no round trip. Now you own ordering, staleness, and the question of whether this user is even allowed to see every field in that payload.
Choose per event type. Signals for anything complex, permission sensitive or infrequent. Payloads for high frequency, low value updates such as a progress percentage. Never both for the same entity, because then two code paths write the same client state and they will disagree.
If you use payloads, include a version and drop anything older than what you already hold. Ordering across a network is not a guarantee you have.
Step 3. Make reconnection lossless
Connections drop. On mobile they drop constantly, and a laptop lid closing is a disconnection.
The default behaviour is that everything emitted during the gap is lost silently, and the client carries on with stale state believing it is current. That is worse than not having realtime at all, because the user now trusts a screen that is wrong.
Give every event a monotonic id, and let the client resume from the last one it saw.
id: 10482
event: invoice.updated
data: {...}
reconnect with Last-Event-ID: 10482
-> server replays 10483 onward from a short buffer
With server sent events the browser does this natively, resending the last id on reconnect without any client code. With WebSockets you implement it yourself.
Buffer for a bounded window: minutes, not hours, in memory or in Redis. If the client has been away longer than the buffer, tell it so explicitly and have it do a full refetch. An honest “you were away, reloading” is dramatically better than silently missing four events.
Verify: disconnect the network, cause three changes server side, reconnect, and confirm the client ends up in the correct state without a manual refresh.
Step 4. Scope every event, on the server
Two rules that are easy to violate and expensive to violate.
Never broadcast and filter on the client. Sending every tenant’s events to every connection and filtering in JavaScript means the data was already delivered to the browser. It is in the network panel. That is a breach, not a UI decision.
Resolve the subscription set at connect time, from the session. Not from anything the client sends. A client that can ask for a channel is a client that can ask for somebody else’s channel.
on connect:
identify user from the session
derive the tenant and the permitted channels on the server
subscribe to exactly those
Step 5. Fail gracefully, because it will fail
Realtime is an enhancement. The application must work without it.
Every screen that receives live updates also needs a way to get its state without them: an ordinary fetch on load and a visible refresh control. If the stream is the only path to correct data, then a proxy that buffers your responses or a corporate network that blocks the connection turns your product into a blank screen.
Show connection state honestly but quietly. A small indicator when the stream is down, and a reconnect that backs off exponentially with jitter so ten thousand clients do not all retry in the same second after a deploy.
The checklist
- One connection per application, owned by one manager.
- Events typed, components subscribe through a bus.
- Signal or payload chosen per type, never both for one entity.
- Monotonic event ids, resume on reconnect, bounded buffer, explicit full refetch beyond it.
- Subscriptions derived on the server from the session, never from client input.
- Every screen works without the stream, with a manual refresh available.
- Backoff with jitter on reconnect.
What good looks like
One connection in the network panel. Closing the laptop for ten minutes and reopening it leaves the screen correct. Turning the stream off entirely degrades the product to something slightly less live rather than something broken. And adding the fifth realtime feature costs an event type rather than a connection.
Questions people actually ask
- Why does one connection matter so much?
- Server side it is the difference between holding one connection per user and four. Client side it is one reconnection strategy instead of four that drift apart. And on HTTP/1.1 the six connections per origin limit is real, so four streams can starve your ordinary requests.
- Should the event carry the data or just a signal?
- Pick one per event type and be consistent. Signals are simpler and always correct, at the cost of a round trip. Payloads are faster and require you to handle ordering and staleness. Mixing both for the same entity is what produces state nobody can reason about.
- What happens to events while a client is disconnected?
- Whatever you designed. If you designed nothing, they are lost silently, which is the default and the worst option. Give events a monotonic id, let the client send back the last one it saw, and replay the gap from a short buffer.