Back to System Design
System Design
hard
senior

How would you design the frontend for a real time collaborative dashboard?

Streaming transport (WS or SSE) feeds a normalized client store; the UI subscribes to slices via selectors. Reconciliation, presence, conflict resolution, backpressure, reconnect/replay are explicit design choices.

9 min read·~30 min to think through

Walk an interviewer through these layers:

1. Requirements / clarifying questions.

  • How many concurrent viewers per board? Per cell?
  • Read-only or read-write? Conflict semantics if write?
  • Latency budget (≤200ms typical for "real-time").
  • Mobile + offline?

2. Transport.

  • Read-only feed → SSE (HTTP/2, auto-reconnect, simpler ops).
  • Bidirectional / collab editing → WebSocket.
  • Always plan reconnect with Last-Event-ID (SSE) or sequence numbers (WS) for replay.

3. Data model. Normalize entities into a flat store keyed by id (Redux/Zustand/Apollo cache). Each component subscribes to a selector — only the affected components re-render on a delta.

4. Server → client deltas. Send patches, not full snapshots. JSON Patch / custom op log. On reconnect, request "since seq N" so the client replays missed ops.

5. Conflict resolution (if write).

  • Last-write-wins — simplest. OK for non-collaborative widgets.
  • Operational transforms or CRDTs — for collaborative editing (Yjs/Automerge).

6. Presence and cursors. A separate ephemeral channel for "who's viewing / typing". Don't mix with persisted state.

7. Backpressure. A high-frequency stream can flood the UI. Coalesce updates per animation frame; throttle aggregator queries.

8. UX for connection state. Show connection status, retry status, last-synced time. Users tolerate problems they can see.

9. Scaling. Server side: pub/sub (Redis, NATS) so any pod can deliver to any client. Sticky sessions for WS. Auth at upgrade time.

10. Observability. Per-message latency, dropped-message counts, reconnect frequency.

Code

tsx
// Zustand store
const useDashboard = create<State>((set) => ({
  cells: {},
  applyDelta: (op) => set((s) => ({ cells: applyOp(s.cells, op) })),
}));

// Each cell only re-renders on its own data
function Cell({ id }: { id: string }) {
  const cell = useDashboard((s) => s.cells[id]);
  return <Tile data={cell} />;
}

// Stream
const ws = new WebSocket(url);
ws.onmessage = (e) => useDashboard.getState().applyDelta(JSON.parse(e.data));
Selector-based subscription minimizes re-renders

Follow-up questions

  • How do you handle a slow consumer that can't keep up with the stream?
  • How would you replay missed events after a reconnect?
  • How do you design the conflict-resolution UI for a collaborative cell edit?

Common mistakes

  • Re-rendering the whole dashboard on every message — selector subscriptions are essential.
  • Sending full snapshots instead of deltas.
  • No reconnect/replay — a brief disconnect leaves the UI silently stale.

Performance considerations

  • Coalesce N updates per rAF tick. Diffing once per frame is far cheaper than per-message setState.

Edge cases

  • Tab in background — browsers throttle timers; the stream can fall behind. Mark as stale, refresh on focus.
  • Auth token expiry mid-session — refresh and reconnect transparently.

Real-world examples

  • Linear, Notion, Figma, Datadog dashboards — all use a streaming transport + normalized client store + selector subscriptions.

Senior engineer discussion

Senior signal: discuss CRDT vs OT, fan-out scaling (pub/sub), per-tenant isolation, observability, and graceful degradation when the stream drops.

Related questions