Live data is easy to demo and hard to keep honest. A dashboard that updates in real time looks great on office Wi-Fi. Then a user opens it on a train, the socket drops twice, and the screen quietly shows figures from ten minutes ago with no sign that anything is wrong.
The mistake I see most often: every screen opens its own socket, parses raw messages and reconnects in a slightly different way. It works until it doesn't, and when it doesn't, nobody can say which screen is right.
Here is the structure I use instead, from building live reservation updates, payment notifications and patient monitoring screens.
One connection, one service#
Keep sockets and polling behind a small service layer. Components never see a raw socket; they subscribe to something typed and domain-shaped: "reservation updated", not {"t":"evt","p":...}.
export type LiveEvent =
| { type: 'reservation.updated'; reservation: Reservation }
| { type: 'payment.received'; payment: Payment };
@Injectable({ providedIn: 'root' })
export class LiveService {
private readonly socket$ = webSocket<RawMessage>(environment.liveUrl);
readonly events$: Observable<LiveEvent> = this.socket$.pipe(
map(parseLiveEvent),
share(),
);
reservations$ = this.events$.pipe(
filter((e): e is Extract<LiveEvent, { type: 'reservation.updated' }> => e.type === 'reservation.updated'),
);
}
Three things this buys you:
- One place to reconnect. Backoff, re-authentication and resubscribing live in the service, not in fifteen components.
- One place to parse. When the backend changes a field name, you change
parseLiveEventand the compiler tells you what else broke. - Shared connection.
share()means ten subscribers use one socket, and the socket closes when the last one leaves.
Networks drop. Show it.#
Do not pretend the user is always online. The service should expose connection state alongside the data, and the UI should use it:
readonly status = signal<'live' | 'reconnecting' | 'offline'>('offline');
- Live: nothing special; maybe a small dot.
- Reconnecting: keep the last data on screen, dim it slightly, say "Reconnecting…".
- Offline for longer than a few seconds: say so plainly and show when the data was last updated.
A stale number that looks live is worse than an empty screen. Users make decisions on it.
Retry with backoff, then stop#
Reconnect immediately once, then wait longer each time (one second, two, four, up to a cap), with a little randomness so a thousand clients don't hammer the server at the same moment after an outage. After a reasonable number of attempts, stop and give the user a "Retry" button. Endless silent retries drain phone batteries and hide real outages from your monitoring.
RxJS retry({ delay }) with a growing timer handles most of this in a few lines; the important part is that it lives in the service.
Polling is not a failure#
Not every screen needs a socket. A list that changes a few times an hour is fine on a timer, and polling is simpler to reason about, works through every proxy, and recovers from drops by itself. Put polling behind the same service interface so a component doesn't know or care which transport is underneath. You can then switch a screen from polling to push later without touching it.
Test the message shapes#
Backend will change. Write a few tests around the message shapes you depend on, using real captured payloads as fixtures. When a field is renamed, the contract breaks in CI instead of during a customer demo. These are the cheapest tests you will ever write and the ones that catch the most embarrassing bugs.
What to take away#
- One service owns the connection; components get typed, domain-shaped events.
- Show connection state and last-updated time. Never let stale data look live.
- Back off on reconnect, then stop and let the user retry.
- Polling behind the same interface is a perfectly good first step.
- Test the message contract with real payloads.