Skip to content

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 parseLiveEvent and 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.

Where each concern lives: Components (Subscribe to typed streams; show status and last-updated time), then Live service (Connect, parse, reconnect with backoff, share one socket), then Transport (WebSocket, with polling as the fallback), then Backend (Raw messages; the contract you test against)ComponentsSubscribe to typed streams; show status and last-updated timeLive serviceConnect, parse, reconnect with backoff, share one socketTransportWebSocket, with polling as the fallbackBackendRaw messages; the contract you test against
Where each concern lives

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.